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,164 @@
|
|
|
1
|
+
"""Abstract interface for a repository knowledge graph.
|
|
2
|
+
|
|
3
|
+
This module is the boundary between ForgeCLI's graph-aware features and any
|
|
4
|
+
concrete back-end (Graphify today; custom AST-based backends tomorrow). The
|
|
5
|
+
interface is intentionally small: build, query, explain, path, affected.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from collections.abc import Iterable
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class GraphNode:
|
|
18
|
+
"""A typed view of a single node from the knowledge graph."""
|
|
19
|
+
|
|
20
|
+
id: str
|
|
21
|
+
label: str
|
|
22
|
+
file_type: str | None = None
|
|
23
|
+
source_file: str | None = None
|
|
24
|
+
source_location: str | None = None
|
|
25
|
+
community: int | None = None
|
|
26
|
+
norm_label: str | None = None
|
|
27
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class GraphEdge:
|
|
32
|
+
"""A typed view of a directed/undirected edge."""
|
|
33
|
+
|
|
34
|
+
source: str
|
|
35
|
+
target: str
|
|
36
|
+
relation: str
|
|
37
|
+
confidence: str | None = None
|
|
38
|
+
confidence_score: float | None = None
|
|
39
|
+
source_file: str | None = None
|
|
40
|
+
source_location: str | None = None
|
|
41
|
+
weight: float = 1.0
|
|
42
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class GraphCommunity:
|
|
47
|
+
"""A Leiden-detected community of nodes."""
|
|
48
|
+
|
|
49
|
+
id: int
|
|
50
|
+
size: int
|
|
51
|
+
label: str | None = None
|
|
52
|
+
members: tuple[str, ...] = ()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class GraphSnapshot:
|
|
57
|
+
"""An immutable view of a fully-built knowledge graph."""
|
|
58
|
+
|
|
59
|
+
root: str
|
|
60
|
+
nodes: tuple[GraphNode, ...]
|
|
61
|
+
edges: tuple[GraphEdge, ...]
|
|
62
|
+
communities: tuple[GraphCommunity, ...] = ()
|
|
63
|
+
directed: bool = False
|
|
64
|
+
multigraph: bool = False
|
|
65
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
66
|
+
|
|
67
|
+
def node(self, node_id: str) -> GraphNode | None:
|
|
68
|
+
"""Return the node with ``node_id`` or ``None``."""
|
|
69
|
+
for node in self.nodes:
|
|
70
|
+
if node.id == node_id:
|
|
71
|
+
return node
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
def neighbors(self, node_id: str) -> list[GraphEdge]:
|
|
75
|
+
"""Return all edges incident to ``node_id`` (in or out)."""
|
|
76
|
+
return [e for e in self.edges if e.source == node_id or e.target == node_id]
|
|
77
|
+
|
|
78
|
+
def search(self, query: str, *, limit: int = 20) -> list[GraphNode]:
|
|
79
|
+
"""Return nodes whose ``label`` or ``norm_label`` contain ``query``."""
|
|
80
|
+
needle = query.lower()
|
|
81
|
+
hits: list[GraphNode] = []
|
|
82
|
+
for node in self.nodes:
|
|
83
|
+
if needle in (node.label or "").lower() or needle in (node.norm_label or "").lower():
|
|
84
|
+
hits.append(node)
|
|
85
|
+
if len(hits) >= limit:
|
|
86
|
+
break
|
|
87
|
+
return hits
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class BuildResult:
|
|
92
|
+
"""The outcome of :meth:`RepositoryGraph.build`."""
|
|
93
|
+
|
|
94
|
+
snapshot: GraphSnapshot
|
|
95
|
+
artifacts: dict[str, str] = field(default_factory=dict)
|
|
96
|
+
raw_output: str = ""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class QueryResult:
|
|
101
|
+
"""A natural-language answer produced by :meth:`RepositoryGraph.query`."""
|
|
102
|
+
|
|
103
|
+
question: str
|
|
104
|
+
answer: str
|
|
105
|
+
cited_nodes: tuple[str, ...] = ()
|
|
106
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class ExplainResult:
|
|
111
|
+
"""A plain-language explanation of a node and its neighbors."""
|
|
112
|
+
|
|
113
|
+
target: str
|
|
114
|
+
explanation: str
|
|
115
|
+
related: tuple[GraphNode, ...] = ()
|
|
116
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class RepositoryGraph(ABC):
|
|
120
|
+
"""Strategy interface for any repository knowledge graph backend."""
|
|
121
|
+
|
|
122
|
+
name: str = "abstract"
|
|
123
|
+
|
|
124
|
+
@abstractmethod
|
|
125
|
+
async def is_available(self) -> bool:
|
|
126
|
+
"""Return True if the back-end is installed and runnable."""
|
|
127
|
+
|
|
128
|
+
@abstractmethod
|
|
129
|
+
async def build(self, *, force: bool = False) -> BuildResult:
|
|
130
|
+
"""Build (or rebuild) the graph rooted at the configured path."""
|
|
131
|
+
|
|
132
|
+
@abstractmethod
|
|
133
|
+
async def load(self) -> GraphSnapshot:
|
|
134
|
+
"""Load a previously-built graph from disk without rebuilding."""
|
|
135
|
+
|
|
136
|
+
@abstractmethod
|
|
137
|
+
async def query(self, question: str, *, budget: int = 2000) -> QueryResult:
|
|
138
|
+
"""Ask a free-form question; the back-end traverses the graph."""
|
|
139
|
+
|
|
140
|
+
@abstractmethod
|
|
141
|
+
async def explain(self, target: str) -> ExplainResult:
|
|
142
|
+
"""Return a plain-language explanation of ``target`` and its neighbors."""
|
|
143
|
+
|
|
144
|
+
@abstractmethod
|
|
145
|
+
async def shortest_path(self, a: str, b: str) -> list[GraphEdge]:
|
|
146
|
+
"""Return the shortest edge path between two node labels/ids."""
|
|
147
|
+
|
|
148
|
+
@abstractmethod
|
|
149
|
+
async def affected(
|
|
150
|
+
self, target: str, *, relation: Iterable[str] | None = None, depth: int = 2
|
|
151
|
+
) -> list[GraphEdge]:
|
|
152
|
+
"""Return the reverse-traversal edges from ``target``."""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
__all__ = [
|
|
156
|
+
"BuildResult",
|
|
157
|
+
"ExplainResult",
|
|
158
|
+
"GraphCommunity",
|
|
159
|
+
"GraphEdge",
|
|
160
|
+
"GraphNode",
|
|
161
|
+
"GraphSnapshot",
|
|
162
|
+
"QueryResult",
|
|
163
|
+
"RepositoryGraph",
|
|
164
|
+
]
|
|
@@ -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,136 @@
|
|
|
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("CREATE INDEX IF NOT EXISTS idx_history_ts ON history(ts DESC)")
|
|
54
|
+
|
|
55
|
+
def record(
|
|
56
|
+
self,
|
|
57
|
+
*,
|
|
58
|
+
command: str,
|
|
59
|
+
provider: str | None = None,
|
|
60
|
+
model: str | None = None,
|
|
61
|
+
prompt_tokens: int = 0,
|
|
62
|
+
completion_tokens: int = 0,
|
|
63
|
+
duration_ms: int = 0,
|
|
64
|
+
success: bool = True,
|
|
65
|
+
error: str | None = None,
|
|
66
|
+
) -> int:
|
|
67
|
+
"""Insert a new history row and return its id."""
|
|
68
|
+
cursor = self._store.execute(
|
|
69
|
+
"""
|
|
70
|
+
INSERT INTO history (
|
|
71
|
+
ts, command, provider, model,
|
|
72
|
+
prompt_tokens, completion_tokens,
|
|
73
|
+
duration_ms, success, error
|
|
74
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
75
|
+
""",
|
|
76
|
+
(
|
|
77
|
+
datetime.now(UTC).isoformat(timespec="seconds"),
|
|
78
|
+
command,
|
|
79
|
+
provider,
|
|
80
|
+
model,
|
|
81
|
+
prompt_tokens,
|
|
82
|
+
completion_tokens,
|
|
83
|
+
duration_ms,
|
|
84
|
+
int(success),
|
|
85
|
+
error,
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
self._trim()
|
|
89
|
+
return int(cursor.lastrowid or 0)
|
|
90
|
+
|
|
91
|
+
def list_recent(self, limit: int = 50) -> list[HistoryEntry]:
|
|
92
|
+
"""Return the most recent history entries (newest first)."""
|
|
93
|
+
rows = self._store.execute(
|
|
94
|
+
"""
|
|
95
|
+
SELECT id, ts, command, provider, model,
|
|
96
|
+
prompt_tokens, completion_tokens,
|
|
97
|
+
duration_ms, success, error
|
|
98
|
+
FROM history
|
|
99
|
+
ORDER BY id DESC
|
|
100
|
+
LIMIT ?
|
|
101
|
+
""",
|
|
102
|
+
(limit,),
|
|
103
|
+
).fetchall()
|
|
104
|
+
return [self._to_entry(r) for r in rows]
|
|
105
|
+
|
|
106
|
+
def _trim(self) -> None:
|
|
107
|
+
"""Drop oldest rows past the configured retention limit."""
|
|
108
|
+
row = self._store.execute("SELECT COUNT(*) AS n FROM history").fetchone()
|
|
109
|
+
total = int(row["n"]) if row else 0
|
|
110
|
+
if total <= self._limit:
|
|
111
|
+
return
|
|
112
|
+
excess = total - self._limit
|
|
113
|
+
self._store.execute(
|
|
114
|
+
"""
|
|
115
|
+
DELETE FROM history
|
|
116
|
+
WHERE id IN (
|
|
117
|
+
SELECT id FROM history ORDER BY id ASC LIMIT ?
|
|
118
|
+
)
|
|
119
|
+
""",
|
|
120
|
+
(excess,),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
@staticmethod
|
|
124
|
+
def _to_entry(row: Any) -> HistoryEntry:
|
|
125
|
+
return HistoryEntry(
|
|
126
|
+
id=int(row["id"]),
|
|
127
|
+
timestamp=datetime.fromisoformat(row["ts"]),
|
|
128
|
+
command=str(row["command"]),
|
|
129
|
+
provider=row["provider"],
|
|
130
|
+
model=row["model"],
|
|
131
|
+
prompt_tokens=int(row["prompt_tokens"] or 0),
|
|
132
|
+
completion_tokens=int(row["completion_tokens"] or 0),
|
|
133
|
+
duration_ms=int(row["duration_ms"] or 0),
|
|
134
|
+
success=bool(row["success"]),
|
|
135
|
+
error=row["error"],
|
|
136
|
+
)
|
forgecli/memory/store.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
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("SELECT value FROM schema_meta WHERE key = 'version'").fetchone()
|
|
74
|
+
if row is None:
|
|
75
|
+
self.conn.execute(
|
|
76
|
+
"INSERT INTO schema_meta (key, value) VALUES ('version', ?)",
|
|
77
|
+
(str(self.SCHEMA_VERSION),),
|
|
78
|
+
)
|
|
79
|
+
# Future migrations can be chained here by inspecting ``row``.
|
|
80
|
+
|
|
81
|
+
def execute(self, sql: str, params: tuple[Any, ...] = ()) -> sqlite3.Cursor:
|
|
82
|
+
return self.conn.execute(sql, params)
|
|
83
|
+
|
|
84
|
+
def executemany(self, sql: str, params: list[tuple[Any, ...]]) -> sqlite3.Cursor:
|
|
85
|
+
return self.conn.executemany(sql, params)
|
|
@@ -0,0 +1,13 @@
|
|
|
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
|
+
]
|
|
@@ -0,0 +1,169 @@
|
|
|
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(self, request: ChatRequest) -> OptimizedRequest:
|
|
74
|
+
"""Return an optimized copy of ``request``."""
|
|
75
|
+
|
|
76
|
+
async def is_available(self) -> bool:
|
|
77
|
+
"""Return whether this optimizer is available to use."""
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class CavemanCompositeOptimizer(CavemanPromptOptimizer):
|
|
82
|
+
"""Pick the right optimizer for the configured :class:`CavemanIntensity`."""
|
|
83
|
+
|
|
84
|
+
name = "caveman-composite"
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
*,
|
|
89
|
+
intensity: CavemanIntensity = CavemanIntensity.LITE,
|
|
90
|
+
ruleset: CavemanPromptOptimizer | None = None,
|
|
91
|
+
external: CavemanPromptOptimizer | None = None,
|
|
92
|
+
) -> None:
|
|
93
|
+
self._intensity = intensity
|
|
94
|
+
self._ruleset = ruleset
|
|
95
|
+
self._external = external
|
|
96
|
+
self._sync_ruleset_intensity()
|
|
97
|
+
|
|
98
|
+
def _sync_ruleset_intensity(self) -> None:
|
|
99
|
+
"""Propagate the composite's intensity to the ruleset, if compatible."""
|
|
100
|
+
ruleset = self._ruleset
|
|
101
|
+
if isinstance(ruleset, CavemanRulesetOptimizer):
|
|
102
|
+
ruleset.set_intensity(self._intensity)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def intensity(self) -> CavemanIntensity:
|
|
106
|
+
return self._intensity
|
|
107
|
+
|
|
108
|
+
def set_intensity(self, intensity: CavemanIntensity | str) -> None:
|
|
109
|
+
self._intensity = CavemanIntensity.parse(intensity)
|
|
110
|
+
if isinstance(self._ruleset, CavemanRulesetOptimizer):
|
|
111
|
+
self._ruleset.set_intensity(self._intensity)
|
|
112
|
+
|
|
113
|
+
async def optimize_chat(self, request: ChatRequest) -> OptimizedRequest:
|
|
114
|
+
if self._intensity is CavemanIntensity.OFF:
|
|
115
|
+
return OptimizedRequest(
|
|
116
|
+
request=request,
|
|
117
|
+
notes=("caveman off",),
|
|
118
|
+
intensity=self._intensity,
|
|
119
|
+
source="caveman-passthrough",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
if self._external is not None and await self._external.is_available():
|
|
123
|
+
result = await self._external.optimize_chat(request)
|
|
124
|
+
return OptimizedRequest(
|
|
125
|
+
request=result.request,
|
|
126
|
+
notes=(*result.notes, "caveman external"),
|
|
127
|
+
intensity=self._intensity,
|
|
128
|
+
source="caveman-external",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if self._ruleset is None:
|
|
132
|
+
return OptimizedRequest(
|
|
133
|
+
request=request,
|
|
134
|
+
notes=("no caveman ruleset registered",),
|
|
135
|
+
intensity=self._intensity,
|
|
136
|
+
source="caveman-passthrough",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
result = await self._ruleset.optimize_chat(request)
|
|
140
|
+
return OptimizedRequest(
|
|
141
|
+
request=result.request,
|
|
142
|
+
notes=result.notes,
|
|
143
|
+
intensity=self._intensity,
|
|
144
|
+
source="caveman-ruleset",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _ensure_user_message(messages: Sequence[ChatMessage]) -> bool:
|
|
149
|
+
return any(m.role.value == "user" for m in messages)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _clone_request(request: ChatRequest, messages: Sequence[ChatMessage]) -> ChatRequest:
|
|
153
|
+
"""Return a copy of ``request`` with ``messages`` replaced."""
|
|
154
|
+
return request.model_copy(update={"messages": list(messages)})
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
__all__ = [
|
|
158
|
+
"CavemanCLIOptimizer",
|
|
159
|
+
"CavemanCompositeOptimizer",
|
|
160
|
+
"CavemanIntensity",
|
|
161
|
+
"CavemanPromptOptimizer",
|
|
162
|
+
"CavemanProvider",
|
|
163
|
+
"CavemanRulesetOptimizer",
|
|
164
|
+
"OptimizedRequest",
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
from forgecli.optimizer.caveman.cli import CavemanCLIOptimizer # noqa: E402
|
|
168
|
+
from forgecli.optimizer.caveman.decorator import CavemanProvider # noqa: E402
|
|
169
|
+
from forgecli.optimizer.caveman.ruleset import CavemanRulesetOptimizer # noqa: E402
|