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.
- 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 +137 -0
- forgecli/cli/commands_wrappers.py +63 -0
- forgecli/cli/main.py +122 -0
- forgecli/cli/ui.py +56 -0
- forgecli/config/__init__.py +28 -0
- forgecli/config/loader.py +85 -0
- forgecli/config/settings.py +204 -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 +159 -0
- forgecli/core/plugins.py +56 -0
- forgecli/core/service.py +22 -0
- forgecli/docs/__init__.py +5 -0
- forgecli/docs/generator.py +119 -0
- forgecli/engine/__init__.py +105 -0
- forgecli/engine/context.py +171 -0
- forgecli/engine/defaults.py +89 -0
- forgecli/engine/events.py +185 -0
- forgecli/engine/execution.py +526 -0
- forgecli/engine/plugins.py +146 -0
- forgecli/engine/runner.py +155 -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 +108 -0
- forgecli/engine/stages/git_engine.py +30 -0
- forgecli/engine/stages/intent_analyzer.py +38 -0
- forgecli/engine/stages/model_router.py +66 -0
- forgecli/engine/stages/planning_engine.py +45 -0
- forgecli/engine/stages/repository_analyzer.py +54 -0
- forgecli/engine/stages/validation_engine.py +89 -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 +453 -0
- forgecli/graph/edge.py +19 -0
- forgecli/graph/graph.py +85 -0
- forgecli/graph/graphify.py +412 -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 +138 -0
- forgecli/memory/store.py +87 -0
- forgecli/optimizer/__init__.py +14 -0
- forgecli/optimizer/caveman/__init__.py +173 -0
- forgecli/optimizer/caveman/cli.py +64 -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 +52 -0
- forgecli/optimizer/chunker.py +85 -0
- forgecli/optimizer/optimizer.py +81 -0
- forgecli/optimizer/ponytail/__init__.py +183 -0
- forgecli/optimizer/ponytail/cli.py +168 -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 +53 -0
- forgecli/optimizer/ranker.py +37 -0
- forgecli/optimizer/summarizer.py +44 -0
- forgecli/orchestrator/__init__.py +707 -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 +271 -0
- forgecli/planner/serialize.py +106 -0
- forgecli/planner/software.py +834 -0
- forgecli/platform/__init__.py +91 -0
- forgecli/platform/core.py +201 -0
- forgecli/platform/deps.py +354 -0
- forgecli/platform/paths.py +236 -0
- forgecli/platform/shell.py +176 -0
- forgecli/platform/update.py +252 -0
- forgecli/plugins/__init__.py +251 -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 +206 -0
- forgecli/providers/base.py +206 -0
- forgecli/providers/builtin.py +26 -0
- forgecli/providers/conversation.py +78 -0
- forgecli/providers/google.py +295 -0
- forgecli/providers/http_base.py +211 -0
- forgecli/providers/mock.py +113 -0
- forgecli/providers/openai.py +202 -0
- forgecli/providers/openai_compatible.py +728 -0
- forgecli/providers/router.py +345 -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 +258 -0
- forgecli/review/analyzers/complexity.py +167 -0
- forgecli/review/analyzers/dead_code.py +262 -0
- forgecli/review/analyzers/duplicates.py +163 -0
- forgecli/review/analyzers/performance.py +165 -0
- forgecli/review/analyzers/security.py +251 -0
- forgecli/review/finding.py +68 -0
- forgecli/review/report.py +311 -0
- forgecli/review/repository.py +131 -0
- forgecli/review/suggestions.py +98 -0
- forgecli/runtime/__init__.py +6 -0
- forgecli/runtime/cache_store.py +77 -0
- forgecli/runtime/prepare.py +199 -0
- forgecli/runtime/wrappers.py +152 -0
- forgecli/sdk/__init__.py +132 -0
- forgecli/sdk/events.py +206 -0
- forgecli/sdk/interfaces.py +282 -0
- forgecli/sdk/loader.py +243 -0
- forgecli/sdk/manager.py +693 -0
- forgecli/sdk/manifest.py +397 -0
- forgecli/sdk/sandbox.py +197 -0
- forgecli/sdk/version.py +316 -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 +91 -0
- forgecli/utils/ids.py +11 -0
- forgecli/utils/io.py +27 -0
- forgecli/utils/paths.py +70 -0
- forgecli/utils/timing.py +25 -0
- forgeoptimizer-1.0.0.dist-info/METADATA +169 -0
- forgeoptimizer-1.0.0.dist-info/RECORD +156 -0
- forgeoptimizer-1.0.0.dist-info/WHEEL +4 -0
- forgeoptimizer-1.0.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|
forgecli/memory/cache.py
ADDED
|
@@ -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
|
+
)
|
forgecli/memory/store.py
ADDED
|
@@ -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"]
|