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,12 @@
1
+ """Local memory store and history (SQLite-backed)."""
2
+
3
+ from forgecli.memory.cache import Cache
4
+ from forgecli.memory.history import HistoryEntry, HistoryRepository
5
+ from forgecli.memory.store import MemoryStore
6
+
7
+ __all__ = [
8
+ "Cache",
9
+ "HistoryEntry",
10
+ "HistoryRepository",
11
+ "MemoryStore",
12
+ ]
@@ -0,0 +1,61 @@
1
+ """Lightweight in-memory cache with optional TTL.
2
+
3
+ A future iteration can swap this for SQLite-backed or Redis-backed caching
4
+ without affecting call sites.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from dataclasses import dataclass
11
+ from threading import RLock
12
+ from typing import Generic, TypeVar
13
+
14
+ K = TypeVar("K")
15
+ V = TypeVar("V")
16
+
17
+
18
+ @dataclass
19
+ class _Entry(Generic[V]):
20
+ value: V
21
+ expires_at: float | None # epoch seconds, or None for "no expiry"
22
+
23
+
24
+ class Cache(Generic[K, V]):
25
+ """Thread-safe key/value cache supporting optional per-entry TTL."""
26
+
27
+ def __init__(self, *, default_ttl: float | None = None) -> None:
28
+ self._store: dict[K, _Entry[V]] = {}
29
+ self._default_ttl = default_ttl
30
+ self._lock = RLock()
31
+
32
+ def get(self, key: K) -> V | None:
33
+ with self._lock:
34
+ entry = self._store.get(key)
35
+ if entry is None:
36
+ return None
37
+ if entry.expires_at is not None and entry.expires_at < time.time():
38
+ self._store.pop(key, None)
39
+ return None
40
+ return entry.value
41
+
42
+ def set(self, key: K, value: V, *, ttl: float | None = None) -> None:
43
+ effective_ttl = self._default_ttl if ttl is None else ttl
44
+ expires = time.time() + effective_ttl if effective_ttl else None
45
+ with self._lock:
46
+ self._store[key] = _Entry(value=value, expires_at=expires)
47
+
48
+ def delete(self, key: K) -> None:
49
+ with self._lock:
50
+ self._store.pop(key, None)
51
+
52
+ def clear(self) -> None:
53
+ with self._lock:
54
+ self._store.clear()
55
+
56
+ def __contains__(self, key: K) -> bool:
57
+ return self.get(key) is not None
58
+
59
+ def __len__(self) -> int:
60
+ with self._lock:
61
+ return len(self._store)
@@ -0,0 +1,138 @@
1
+ """Append-only history of CLI invocations and AI interactions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import UTC, datetime
7
+ from typing import Any
8
+
9
+ from forgecli.memory.store import MemoryStore
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class HistoryEntry:
14
+ """A single persisted history record."""
15
+
16
+ id: int
17
+ timestamp: datetime
18
+ command: str
19
+ provider: str | None
20
+ model: str | None
21
+ prompt_tokens: int
22
+ completion_tokens: int
23
+ duration_ms: int
24
+ success: bool
25
+ error: str | None
26
+
27
+
28
+ class HistoryRepository:
29
+ """Persistence of CLI/AI invocations keyed by time."""
30
+
31
+ def __init__(self, store: MemoryStore, *, history_limit: int = 1000) -> None:
32
+ self._store = store
33
+ self._limit = history_limit
34
+ self._ensure_schema()
35
+
36
+ def _ensure_schema(self) -> None:
37
+ self._store.execute(
38
+ """
39
+ CREATE TABLE IF NOT EXISTS history (
40
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
41
+ ts TEXT NOT NULL,
42
+ command TEXT NOT NULL,
43
+ provider TEXT,
44
+ model TEXT,
45
+ prompt_tokens INTEGER NOT NULL DEFAULT 0,
46
+ completion_tokens INTEGER NOT NULL DEFAULT 0,
47
+ duration_ms INTEGER NOT NULL DEFAULT 0,
48
+ success INTEGER NOT NULL DEFAULT 1,
49
+ error TEXT
50
+ )
51
+ """
52
+ )
53
+ self._store.execute(
54
+ "CREATE INDEX IF NOT EXISTS idx_history_ts ON history(ts DESC)"
55
+ )
56
+
57
+ def record(
58
+ self,
59
+ *,
60
+ command: str,
61
+ provider: str | None = None,
62
+ model: str | None = None,
63
+ prompt_tokens: int = 0,
64
+ completion_tokens: int = 0,
65
+ duration_ms: int = 0,
66
+ success: bool = True,
67
+ error: str | None = None,
68
+ ) -> int:
69
+ """Insert a new history row and return its id."""
70
+ cursor = self._store.execute(
71
+ """
72
+ INSERT INTO history (
73
+ ts, command, provider, model,
74
+ prompt_tokens, completion_tokens,
75
+ duration_ms, success, error
76
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
77
+ """,
78
+ (
79
+ datetime.now(UTC).isoformat(timespec="seconds"),
80
+ command,
81
+ provider,
82
+ model,
83
+ prompt_tokens,
84
+ completion_tokens,
85
+ duration_ms,
86
+ int(success),
87
+ error,
88
+ ),
89
+ )
90
+ self._trim()
91
+ return int(cursor.lastrowid or 0)
92
+
93
+ def list_recent(self, limit: int = 50) -> list[HistoryEntry]:
94
+ """Return the most recent history entries (newest first)."""
95
+ rows = self._store.execute(
96
+ """
97
+ SELECT id, ts, command, provider, model,
98
+ prompt_tokens, completion_tokens,
99
+ duration_ms, success, error
100
+ FROM history
101
+ ORDER BY id DESC
102
+ LIMIT ?
103
+ """,
104
+ (limit,),
105
+ ).fetchall()
106
+ return [self._to_entry(r) for r in rows]
107
+
108
+ def _trim(self) -> None:
109
+ """Drop oldest rows past the configured retention limit."""
110
+ row = self._store.execute("SELECT COUNT(*) AS n FROM history").fetchone()
111
+ total = int(row["n"]) if row else 0
112
+ if total <= self._limit:
113
+ return
114
+ excess = total - self._limit
115
+ self._store.execute(
116
+ """
117
+ DELETE FROM history
118
+ WHERE id IN (
119
+ SELECT id FROM history ORDER BY id ASC LIMIT ?
120
+ )
121
+ """,
122
+ (excess,),
123
+ )
124
+
125
+ @staticmethod
126
+ def _to_entry(row: Any) -> HistoryEntry:
127
+ return HistoryEntry(
128
+ id=int(row["id"]),
129
+ timestamp=datetime.fromisoformat(row["ts"]),
130
+ command=str(row["command"]),
131
+ provider=row["provider"],
132
+ model=row["model"],
133
+ prompt_tokens=int(row["prompt_tokens"] or 0),
134
+ completion_tokens=int(row["completion_tokens"] or 0),
135
+ duration_ms=int(row["duration_ms"] or 0),
136
+ success=bool(row["success"]),
137
+ error=row["error"],
138
+ )
@@ -0,0 +1,87 @@
1
+ """SQLite-backed local memory store (singleton lifecycle owned by AppContext)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from forgecli.utils.fs import ensure_dir
10
+
11
+
12
+ class MemoryStore:
13
+ """Thin wrapper around a local SQLite database.
14
+
15
+ The store is intentionally minimal: it owns the connection and provides
16
+ a few named-table helpers. Higher-level repositories (e.g. history)
17
+ layer domain logic on top.
18
+ """
19
+
20
+ SCHEMA_VERSION = 1
21
+
22
+ def __init__(self, db_path: Path) -> None:
23
+ self._db_path = Path(db_path)
24
+ ensure_dir(self._db_path.parent)
25
+ self._conn: sqlite3.Connection | None = None
26
+
27
+ @property
28
+ def db_path(self) -> Path:
29
+ return self._db_path
30
+
31
+ def connect(self) -> None:
32
+ """Open the database and run schema migrations."""
33
+ if self._conn is not None:
34
+ return
35
+ self._conn = sqlite3.connect(
36
+ str(self._db_path),
37
+ check_same_thread=False,
38
+ isolation_level=None, # autocommit; we use explicit transactions
39
+ )
40
+ self._conn.row_factory = sqlite3.Row
41
+ self._conn.execute("PRAGMA journal_mode = WAL")
42
+ self._conn.execute("PRAGMA foreign_keys = ON")
43
+ self._migrate()
44
+
45
+ def close(self) -> None:
46
+ """Close the underlying connection if open."""
47
+ if self._conn is not None:
48
+ self._conn.close()
49
+ self._conn = None
50
+
51
+ def __enter__(self) -> MemoryStore:
52
+ self.connect()
53
+ return self
54
+
55
+ def __exit__(self, *exc: Any) -> None:
56
+ self.close()
57
+
58
+ @property
59
+ def conn(self) -> sqlite3.Connection:
60
+ if self._conn is None:
61
+ raise RuntimeError("MemoryStore.connect() must be called first")
62
+ return self._conn
63
+
64
+ def _migrate(self) -> None:
65
+ self.conn.execute(
66
+ """
67
+ CREATE TABLE IF NOT EXISTS schema_meta (
68
+ key TEXT PRIMARY KEY,
69
+ value TEXT NOT NULL
70
+ )
71
+ """
72
+ )
73
+ row = self.conn.execute(
74
+ "SELECT value FROM schema_meta WHERE key = 'version'"
75
+ ).fetchone()
76
+ if row is None:
77
+ self.conn.execute(
78
+ "INSERT INTO schema_meta (key, value) VALUES ('version', ?)",
79
+ (str(self.SCHEMA_VERSION),),
80
+ )
81
+ # Future migrations can be chained here by inspecting ``row``.
82
+
83
+ def execute(self, sql: str, params: tuple[Any, ...] = ()) -> sqlite3.Cursor:
84
+ return self.conn.execute(sql, params)
85
+
86
+ def executemany(self, sql: str, params: list[tuple[Any, ...]]) -> sqlite3.Cursor:
87
+ return self.conn.executemany(sql, params)
@@ -0,0 +1,14 @@
1
+ """Context optimization: chunking, ranking, summarization, token optimization."""
2
+
3
+ from forgecli.optimizer.chunker import Chunker
4
+ from forgecli.optimizer.optimizer import ContextOptimizer
5
+ from forgecli.optimizer.ranker import Ranker
6
+ from forgecli.optimizer.summarizer import Summarizer
7
+
8
+ __all__ = [
9
+ "Chunker",
10
+ "ContextOptimizer",
11
+ "Ranker",
12
+ "Summarizer",
13
+ ]
14
+
@@ -0,0 +1,173 @@
1
+ """Caveman token-optimizer integration.
2
+
3
+ Caveman is a prompt-level optimization technique that reduces LLM output
4
+ tokens by ~65% on average. It works by injecting a system prompt that
5
+ tells the model to communicate in a compressed, token-efficient style —
6
+ dropping filler words, pleasantries, and using fragments — while keeping
7
+ all technical terms exact.
8
+
9
+ Two implementations are shipped:
10
+
11
+ * :class:`CavemanRulesetOptimizer` — a self-contained Python
12
+ implementation of the Caveman ruleset (lite / full / ultra / wenyan).
13
+ Always available, no external dependencies.
14
+ * :class:`CavemanCLIOptimizer` — an optional adapter that shells out
15
+ to an external ``caveman`` binary if one is on ``PATH``.
16
+
17
+ The :class:`CavemanCompositeOptimizer` picks between them at runtime.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from abc import ABC, abstractmethod
23
+ from collections.abc import Sequence
24
+ from enum import Enum
25
+
26
+ from forgecli.optimizer.ponytail import OptimizedRequest
27
+ from forgecli.providers.base import ChatMessage, ChatRequest
28
+
29
+
30
+ class CavemanIntensity(str, Enum):
31
+ """How aggressively to compress LLM output.
32
+
33
+ * ``off`` - no rewriting; pass prompts through unchanged.
34
+ * ``lite`` - be concise; drop filler words and pleasantries.
35
+ * ``full`` - full caveman: fragments, [thing][action][reason] pattern.
36
+ * ``ultra`` - maximum compression; grunt-level communication.
37
+ * ``wenyan`` - Classical Chinese literary style (文言) for max density.
38
+ """
39
+
40
+ OFF = "off"
41
+ LITE = "lite"
42
+ FULL = "full"
43
+ ULTRA = "ultra"
44
+ WENYAN = "wenyan"
45
+
46
+ @classmethod
47
+ def parse(cls, value: str | CavemanIntensity | None) -> CavemanIntensity:
48
+ """Parse a string into an :class:`CavemanIntensity`, falling back to LITE."""
49
+ if value is None or value == "":
50
+ return cls.LITE
51
+ if isinstance(value, cls):
52
+ return value
53
+ try:
54
+ return cls(value.lower())
55
+ except ValueError as exc:
56
+ raise ValueError(
57
+ f"Unknown caveman intensity {value!r}; expected one of "
58
+ f"{', '.join(i.value for i in cls)}"
59
+ ) from exc
60
+
61
+
62
+ class CavemanPromptOptimizer(ABC):
63
+ """Strategy interface for Caveman prompt optimization.
64
+
65
+ Implementations must be deterministic and side-effect free
66
+ (other than logging), so they can be invoked transparently before
67
+ every model call.
68
+ """
69
+
70
+ name: str = "abstract-caveman"
71
+
72
+ @abstractmethod
73
+ async def optimize_chat(
74
+ self, request: ChatRequest
75
+ ) -> OptimizedRequest:
76
+ """Return an optimized copy of ``request``."""
77
+
78
+ async def is_available(self) -> bool:
79
+ """Return whether this optimizer is available to use."""
80
+ return True
81
+
82
+
83
+ class CavemanCompositeOptimizer(CavemanPromptOptimizer):
84
+ """Pick the right optimizer for the configured :class:`CavemanIntensity`."""
85
+
86
+ name = "caveman-composite"
87
+
88
+ def __init__(
89
+ self,
90
+ *,
91
+ intensity: CavemanIntensity = CavemanIntensity.LITE,
92
+ ruleset: CavemanPromptOptimizer | None = None,
93
+ external: CavemanPromptOptimizer | None = None,
94
+ ) -> None:
95
+ self._intensity = intensity
96
+ self._ruleset = ruleset
97
+ self._external = external
98
+ self._sync_ruleset_intensity()
99
+
100
+ def _sync_ruleset_intensity(self) -> None:
101
+ """Propagate the composite's intensity to the ruleset, if compatible."""
102
+ ruleset = self._ruleset
103
+ if isinstance(ruleset, CavemanRulesetOptimizer):
104
+ ruleset.set_intensity(self._intensity)
105
+
106
+ @property
107
+ def intensity(self) -> CavemanIntensity:
108
+ return self._intensity
109
+
110
+ def set_intensity(self, intensity: CavemanIntensity | str) -> None:
111
+ self._intensity = CavemanIntensity.parse(intensity)
112
+ if isinstance(self._ruleset, CavemanRulesetOptimizer):
113
+ self._ruleset.set_intensity(self._intensity)
114
+
115
+ async def optimize_chat(self, request: ChatRequest) -> OptimizedRequest:
116
+ if self._intensity is CavemanIntensity.OFF:
117
+ return OptimizedRequest(
118
+ request=request,
119
+ notes=("caveman off",),
120
+ intensity=self._intensity,
121
+ source="caveman-passthrough",
122
+ )
123
+
124
+ if self._external is not None and await self._external.is_available():
125
+ result = await self._external.optimize_chat(request)
126
+ return OptimizedRequest(
127
+ request=result.request,
128
+ notes=(*result.notes, "caveman external"),
129
+ intensity=self._intensity,
130
+ source="caveman-external",
131
+ )
132
+
133
+ if self._ruleset is None:
134
+ return OptimizedRequest(
135
+ request=request,
136
+ notes=("no caveman ruleset registered",),
137
+ intensity=self._intensity,
138
+ source="caveman-passthrough",
139
+ )
140
+
141
+ result = await self._ruleset.optimize_chat(request)
142
+ return OptimizedRequest(
143
+ request=result.request,
144
+ notes=result.notes,
145
+ intensity=self._intensity,
146
+ source="caveman-ruleset",
147
+ )
148
+
149
+
150
+ def _ensure_user_message(messages: Sequence[ChatMessage]) -> bool:
151
+ return any(m.role.value == "user" for m in messages)
152
+
153
+
154
+ def _clone_request(
155
+ request: ChatRequest, messages: Sequence[ChatMessage]
156
+ ) -> ChatRequest:
157
+ """Return a copy of ``request`` with ``messages`` replaced."""
158
+ return request.model_copy(update={"messages": list(messages)})
159
+
160
+
161
+ __all__ = [
162
+ "CavemanCLIOptimizer",
163
+ "CavemanCompositeOptimizer",
164
+ "CavemanIntensity",
165
+ "CavemanPromptOptimizer",
166
+ "CavemanProvider",
167
+ "CavemanRulesetOptimizer",
168
+ "OptimizedRequest",
169
+ ]
170
+
171
+ from forgecli.optimizer.caveman.cli import CavemanCLIOptimizer # noqa: E402
172
+ from forgecli.optimizer.caveman.decorator import CavemanProvider # noqa: E402
173
+ from forgecli.optimizer.caveman.ruleset import CavemanRulesetOptimizer # noqa: E402
@@ -0,0 +1,64 @@
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(
41
+ None, lambda: shutil.which(self._executable) is not None
42
+ )
43
+
44
+ async def optimize_chat(self, request: ChatRequest) -> OptimizedRequest:
45
+ """Run the external caveman binary and return the optimized request.
46
+
47
+ Currently a stub that returns the request unchanged \u2014 the
48
+ caveman CLI protocol hasn't been standardised yet. Implement
49
+ this method once a stable CLI interface exists.
50
+ """
51
+ _log.debug(
52
+ "caveman CLI adapter called; binary=%s available=%s",
53
+ self._executable,
54
+ await self.is_available(),
55
+ )
56
+ return OptimizedRequest(
57
+ request=request,
58
+ notes=("caveman CLI stub (passthrough)",),
59
+ intensity=CavemanIntensity.LITE,
60
+ source="caveman-cli",
61
+ )
62
+
63
+
64
+ __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"]