contextpilot-ai 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.
- contextpilot/__init__.py +43 -0
- contextpilot/adapters/__init__.py +0 -0
- contextpilot/adapters/anthropic_adapter.py +38 -0
- contextpilot/adapters/openai_adapter.py +39 -0
- contextpilot/analyzer.py +185 -0
- contextpilot/cli.py +161 -0
- contextpilot/compressor.py +64 -0
- contextpilot/config.py +64 -0
- contextpilot/middleware.py +36 -0
- contextpilot/migrate.py +228 -0
- contextpilot/pipeline.py +89 -0
- contextpilot/proxy.py +171 -0
- contextpilot/quality.py +73 -0
- contextpilot/shadow.py +62 -0
- contextpilot/strategies/__init__.py +0 -0
- contextpilot/strategies/agent_memory.py +72 -0
- contextpilot/strategies/dedup.py +36 -0
- contextpilot/strategies/history.py +54 -0
- contextpilot/strategies/rag_pruner.py +64 -0
- contextpilot/strategies/structural.py +40 -0
- contextpilot/telemetry.py +113 -0
- contextpilot/wrapper.py +6 -0
- contextpilot_ai-0.1.0.dist-info/METADATA +160 -0
- contextpilot_ai-0.1.0.dist-info/RECORD +26 -0
- contextpilot_ai-0.1.0.dist-info/WHEEL +4 -0
- contextpilot_ai-0.1.0.dist-info/entry_points.txt +2 -0
contextpilot/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from contextpilot.config import ContextPilotConfig
|
|
6
|
+
from contextpilot.pipeline import Pipeline
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def wrap(client: Any, config: ContextPilotConfig | dict | None = None) -> Any:
|
|
10
|
+
"""Wrap an OpenAI or Anthropic client with ContextPilot compression.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
from openai import OpenAI
|
|
14
|
+
import contextpilot
|
|
15
|
+
pilot = contextpilot.wrap(OpenAI())
|
|
16
|
+
response = pilot.chat.completions.create(model="gpt-4o", messages=messages)
|
|
17
|
+
"""
|
|
18
|
+
if isinstance(config, dict):
|
|
19
|
+
cfg = ContextPilotConfig.model_validate(config)
|
|
20
|
+
elif config is None:
|
|
21
|
+
cfg = ContextPilotConfig.load()
|
|
22
|
+
else:
|
|
23
|
+
cfg = config
|
|
24
|
+
|
|
25
|
+
pipeline = Pipeline(cfg)
|
|
26
|
+
|
|
27
|
+
module = type(client).__module__
|
|
28
|
+
name = type(client).__name__
|
|
29
|
+
|
|
30
|
+
if "openai" in module.lower() or name in ("OpenAI", "AsyncOpenAI"):
|
|
31
|
+
from contextpilot.adapters.openai_adapter import OpenAIWrapper
|
|
32
|
+
return OpenAIWrapper(client, pipeline)
|
|
33
|
+
|
|
34
|
+
if "anthropic" in module.lower() or name in ("Anthropic", "AsyncAnthropic"):
|
|
35
|
+
from contextpilot.adapters.anthropic_adapter import AnthropicWrapper
|
|
36
|
+
return AnthropicWrapper(client, pipeline)
|
|
37
|
+
|
|
38
|
+
raise ValueError(
|
|
39
|
+
f"Unsupported client type '{name}'. Supported: openai.OpenAI, anthropic.Anthropic"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
__all__ = ["wrap", "ContextPilotConfig"]
|
|
File without changes
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextpilot.pipeline import Pipeline
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class _MessagesWrapper:
|
|
7
|
+
def __init__(self, messages_api: object, pipeline: Pipeline) -> None:
|
|
8
|
+
self._messages_api = messages_api
|
|
9
|
+
self._pipeline = pipeline
|
|
10
|
+
|
|
11
|
+
def create(self, *, model: str, messages: list[dict], **kwargs: object) -> object:
|
|
12
|
+
system: str | None = kwargs.pop("system", None) # type: ignore[assignment]
|
|
13
|
+
optimized_msgs, optimized_sys, _ = self._pipeline.optimize(
|
|
14
|
+
messages, system=system, provider="anthropic", model=model
|
|
15
|
+
)
|
|
16
|
+
if optimized_sys is not None:
|
|
17
|
+
kwargs["system"] = optimized_sys
|
|
18
|
+
return self._messages_api.create(model=model, messages=optimized_msgs, **kwargs) # type: ignore[union-attr]
|
|
19
|
+
|
|
20
|
+
def __getattr__(self, name: str) -> object:
|
|
21
|
+
return getattr(self._messages_api, name)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AnthropicWrapper:
|
|
25
|
+
"""FR-001: Drop-in wrapper for anthropic.Anthropic.
|
|
26
|
+
|
|
27
|
+
Intercepts messages.create() calls, runs the compression pipeline on the
|
|
28
|
+
message list and optional system prompt, and forwards the optimised payload.
|
|
29
|
+
All other attributes delegate to the original client unchanged.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, client: object, pipeline: Pipeline) -> None:
|
|
33
|
+
self._client = client
|
|
34
|
+
self._pipeline = pipeline
|
|
35
|
+
self.messages = _MessagesWrapper(client.messages, pipeline) # type: ignore[union-attr]
|
|
36
|
+
|
|
37
|
+
def __getattr__(self, name: str) -> object:
|
|
38
|
+
return getattr(self._client, name)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextpilot.pipeline import Pipeline
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class _CompletionsWrapper:
|
|
7
|
+
def __init__(self, completions: object, pipeline: Pipeline) -> None:
|
|
8
|
+
self._completions = completions
|
|
9
|
+
self._pipeline = pipeline
|
|
10
|
+
|
|
11
|
+
def create(self, *, model: str, messages: list[dict], **kwargs: object) -> object:
|
|
12
|
+
optimized, _, _ = self._pipeline.optimize(messages, provider="openai", model=model)
|
|
13
|
+
return self._completions.create(model=model, messages=optimized, **kwargs) # type: ignore[union-attr]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _ChatWrapper:
|
|
17
|
+
def __init__(self, chat: object, pipeline: Pipeline) -> None:
|
|
18
|
+
self._chat = chat
|
|
19
|
+
self.completions = _CompletionsWrapper(chat.completions, pipeline) # type: ignore[union-attr]
|
|
20
|
+
|
|
21
|
+
def __getattr__(self, name: str) -> object:
|
|
22
|
+
return getattr(self._chat, name)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class OpenAIWrapper:
|
|
26
|
+
"""FR-001: Drop-in wrapper for openai.OpenAI.
|
|
27
|
+
|
|
28
|
+
Intercepts chat.completions.create() calls, runs the compression pipeline,
|
|
29
|
+
and forwards the optimised payload. All other attributes delegate to the
|
|
30
|
+
original client unchanged.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, client: object, pipeline: Pipeline) -> None:
|
|
34
|
+
self._client = client
|
|
35
|
+
self._pipeline = pipeline
|
|
36
|
+
self.chat = _ChatWrapper(client.chat, pipeline) # type: ignore[union-attr]
|
|
37
|
+
|
|
38
|
+
def __getattr__(self, name: str) -> object:
|
|
39
|
+
return getattr(self._client, name)
|
contextpilot/analyzer.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Sequence
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
|
10
|
+
from sklearn.metrics.pairwise import cosine_similarity
|
|
11
|
+
|
|
12
|
+
from contextpilot.config import ContextPilotConfig
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BlockClass(str, Enum):
|
|
16
|
+
ESSENTIAL = "essential"
|
|
17
|
+
COMPRESSIBLE = "compressible"
|
|
18
|
+
DROPPABLE = "droppable"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class MessageBlock:
|
|
23
|
+
index: int
|
|
24
|
+
role: str
|
|
25
|
+
content: str
|
|
26
|
+
staleness: float # 0.0 = fresh (recent), 1.0 = stale (old)
|
|
27
|
+
redundancy: float # 0.0 = unique, 1.0 = duplicate of another block
|
|
28
|
+
relevance: float # 0.0 = irrelevant, 1.0 = highly relevant to latest query
|
|
29
|
+
density: float # 0.0 = sparse, 1.0 = information-dense
|
|
30
|
+
classification: BlockClass = BlockClass.ESSENTIAL
|
|
31
|
+
token_count: int = 0 # approximate word-based count
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def composite_score(self) -> float:
|
|
35
|
+
"""Value of this block — higher means keep it. Used for triage decisions."""
|
|
36
|
+
return (
|
|
37
|
+
self.relevance * 0.4
|
|
38
|
+
+ self.density * 0.3
|
|
39
|
+
+ (1.0 - self.staleness) * 0.2
|
|
40
|
+
+ (1.0 - self.redundancy) * 0.1
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _word_tokens(text: str) -> list[str]:
|
|
45
|
+
return re.findall(r"\b\w+\b", text.lower())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _density(text: str) -> float:
|
|
49
|
+
tokens = _word_tokens(text)
|
|
50
|
+
if not tokens:
|
|
51
|
+
return 0.0
|
|
52
|
+
return len(set(tokens)) / len(tokens)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _word_count(text: str) -> int:
|
|
56
|
+
return len(text.split())
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Analyzer:
|
|
60
|
+
"""FR-002: Context analysis engine.
|
|
61
|
+
|
|
62
|
+
Scores each message block across four dimensions — staleness, redundancy,
|
|
63
|
+
relevance, density — and classifies it as essential / compressible / droppable.
|
|
64
|
+
Analysis target: < 50 ms for up to 100K tokens (technical doc §8).
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, config: ContextPilotConfig) -> None:
|
|
68
|
+
self.config = config
|
|
69
|
+
|
|
70
|
+
def analyze(
|
|
71
|
+
self, messages: list[dict], system: str | None = None
|
|
72
|
+
) -> list[MessageBlock]:
|
|
73
|
+
n = len(messages)
|
|
74
|
+
if n == 0:
|
|
75
|
+
return []
|
|
76
|
+
|
|
77
|
+
texts = [m.get("content") or "" for m in messages]
|
|
78
|
+
|
|
79
|
+
# Most recent user message drives relevance scoring
|
|
80
|
+
recent_user = next(
|
|
81
|
+
(m.get("content") or "" for m in reversed(messages) if m.get("role") == "user"),
|
|
82
|
+
"",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
redundancies, relevances = self._score_tfidf(texts, recent_user, n)
|
|
86
|
+
|
|
87
|
+
blocks: list[MessageBlock] = []
|
|
88
|
+
for i, msg in enumerate(messages):
|
|
89
|
+
content = texts[i]
|
|
90
|
+
role = msg.get("role", "user")
|
|
91
|
+
# Index 0 = oldest = most stale
|
|
92
|
+
staleness = 1.0 - (i / max(n - 1, 1))
|
|
93
|
+
density = _density(content)
|
|
94
|
+
redundancy = float(redundancies[i])
|
|
95
|
+
relevance = float(relevances[i])
|
|
96
|
+
|
|
97
|
+
classification = self._classify(
|
|
98
|
+
role=role,
|
|
99
|
+
index=i,
|
|
100
|
+
total=n,
|
|
101
|
+
staleness=staleness,
|
|
102
|
+
redundancy=redundancy,
|
|
103
|
+
relevance=relevance,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
blocks.append(
|
|
107
|
+
MessageBlock(
|
|
108
|
+
index=i,
|
|
109
|
+
role=role,
|
|
110
|
+
content=content,
|
|
111
|
+
staleness=staleness,
|
|
112
|
+
redundancy=redundancy,
|
|
113
|
+
relevance=relevance,
|
|
114
|
+
density=density,
|
|
115
|
+
classification=classification,
|
|
116
|
+
token_count=_word_count(content),
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
return blocks
|
|
121
|
+
|
|
122
|
+
def _score_tfidf(
|
|
123
|
+
self, texts: list[str], recent_user: str, n: int
|
|
124
|
+
) -> tuple[list[float], list[float]]:
|
|
125
|
+
corpus = texts + ([recent_user] if recent_user else [])
|
|
126
|
+
try:
|
|
127
|
+
vec = TfidfVectorizer(min_df=1, token_pattern=r"(?u)\b\w+\b")
|
|
128
|
+
tfidf = vec.fit_transform(corpus)
|
|
129
|
+
msg_vecs = tfidf[:n]
|
|
130
|
+
|
|
131
|
+
# Redundancy: max cosine similarity against all *other* messages
|
|
132
|
+
sim = cosine_similarity(msg_vecs)
|
|
133
|
+
np.fill_diagonal(sim, 0.0)
|
|
134
|
+
redundancies = sim.max(axis=1).tolist()
|
|
135
|
+
|
|
136
|
+
# Relevance: cosine similarity to most recent user message
|
|
137
|
+
if recent_user:
|
|
138
|
+
user_vec = tfidf[n]
|
|
139
|
+
relevances = cosine_similarity(msg_vecs, user_vec.reshape(1, -1)).flatten().tolist()
|
|
140
|
+
else:
|
|
141
|
+
relevances = [0.5] * n
|
|
142
|
+
except Exception:
|
|
143
|
+
redundancies = [0.0] * n
|
|
144
|
+
relevances = [0.5] * n
|
|
145
|
+
|
|
146
|
+
return redundancies, relevances
|
|
147
|
+
|
|
148
|
+
def _classify(
|
|
149
|
+
self,
|
|
150
|
+
role: str,
|
|
151
|
+
index: int,
|
|
152
|
+
total: int,
|
|
153
|
+
staleness: float,
|
|
154
|
+
redundancy: float,
|
|
155
|
+
relevance: float,
|
|
156
|
+
) -> BlockClass:
|
|
157
|
+
# System messages and the most recent window are always kept
|
|
158
|
+
if role == "system":
|
|
159
|
+
return BlockClass.ESSENTIAL
|
|
160
|
+
window = self.config.compression.history_window
|
|
161
|
+
if index >= total - window:
|
|
162
|
+
return BlockClass.ESSENTIAL
|
|
163
|
+
|
|
164
|
+
level = self.config.compression.level
|
|
165
|
+
if level == "conservative":
|
|
166
|
+
# Only drop near-exact duplicates
|
|
167
|
+
if redundancy > 0.95:
|
|
168
|
+
return BlockClass.DROPPABLE
|
|
169
|
+
if redundancy > 0.7:
|
|
170
|
+
return BlockClass.COMPRESSIBLE
|
|
171
|
+
return BlockClass.ESSENTIAL
|
|
172
|
+
|
|
173
|
+
if level == "aggressive":
|
|
174
|
+
if redundancy > 0.6 or (staleness > 0.5 and relevance < 0.15):
|
|
175
|
+
return BlockClass.DROPPABLE
|
|
176
|
+
if staleness > 0.3 or redundancy > 0.3:
|
|
177
|
+
return BlockClass.COMPRESSIBLE
|
|
178
|
+
return BlockClass.ESSENTIAL
|
|
179
|
+
|
|
180
|
+
# balanced (default)
|
|
181
|
+
if redundancy > 0.85 or (staleness > 0.7 and relevance < 0.2):
|
|
182
|
+
return BlockClass.DROPPABLE
|
|
183
|
+
if staleness > 0.5 or redundancy > 0.5:
|
|
184
|
+
return BlockClass.COMPRESSIBLE
|
|
185
|
+
return BlockClass.ESSENTIAL
|
contextpilot/cli.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""ContextPilot CLI — entry point for all four surfaces.
|
|
2
|
+
|
|
3
|
+
Commands
|
|
4
|
+
--------
|
|
5
|
+
contextpilot proxy — Surface B: local proxy server (FR-009)
|
|
6
|
+
contextpilot migrate — Surface D: AST-based migration agent (FR-011)
|
|
7
|
+
contextpilot report — Show local token savings summary
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
|
|
16
|
+
_LOCAL_LOG = Path.home() / ".contextpilot" / "events.jsonl"
|
|
17
|
+
|
|
18
|
+
# Rough $/1M-token rates for common models (input side).
|
|
19
|
+
_PRICING: dict[str, float] = {
|
|
20
|
+
"gpt-4o": 5.00,
|
|
21
|
+
"gpt-4o-mini": 0.15,
|
|
22
|
+
"gpt-4-turbo": 10.00,
|
|
23
|
+
"gpt-4": 30.00,
|
|
24
|
+
"gpt-3.5-turbo": 0.50,
|
|
25
|
+
"claude-opus": 15.00,
|
|
26
|
+
"claude-sonnet": 3.00,
|
|
27
|
+
"claude-haiku": 0.25,
|
|
28
|
+
}
|
|
29
|
+
_DEFAULT_RATE = 5.00 # $/1M tokens fallback
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _rate_for(model: str) -> float:
|
|
33
|
+
m = model.lower()
|
|
34
|
+
for key, rate in _PRICING.items():
|
|
35
|
+
if key in m:
|
|
36
|
+
return rate
|
|
37
|
+
return _DEFAULT_RATE
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@click.group()
|
|
41
|
+
@click.version_option(package_name="contextpilot")
|
|
42
|
+
def main() -> None:
|
|
43
|
+
"""ContextPilot — Intelligent LLM context optimization middleware."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Surface B: proxy
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
@main.command()
|
|
51
|
+
@click.option("--port", default=8432, show_default=True, help="TCP port to listen on.")
|
|
52
|
+
@click.option("--host", default="127.0.0.1", show_default=True, help="Address to bind.")
|
|
53
|
+
@click.option("--config", "config_path", default=None, help="Path to contextpilot.yaml.")
|
|
54
|
+
def proxy(port: int, host: str, config_path: str | None) -> None:
|
|
55
|
+
"""Start the local proxy server (Surface B, FR-009).
|
|
56
|
+
|
|
57
|
+
\b
|
|
58
|
+
Set one environment variable to route requests through ContextPilot:
|
|
59
|
+
|
|
60
|
+
Anthropic / Claude Code:
|
|
61
|
+
export ANTHROPIC_BASE_URL=http://localhost:8432
|
|
62
|
+
|
|
63
|
+
OpenAI / GPT Codex / Aider:
|
|
64
|
+
export OPENAI_BASE_URL=http://localhost:8432/v1
|
|
65
|
+
"""
|
|
66
|
+
from contextpilot.proxy import run_proxy
|
|
67
|
+
run_proxy(host=host, port=port, config_path=config_path)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# Surface D: migrate
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
@main.command()
|
|
75
|
+
@click.argument("path", default=".", metavar="PATH")
|
|
76
|
+
@click.option(
|
|
77
|
+
"--dry-run",
|
|
78
|
+
is_flag=True,
|
|
79
|
+
default=False,
|
|
80
|
+
help="Show what would change without writing files (default if neither flag is given).",
|
|
81
|
+
)
|
|
82
|
+
@click.option("--apply", "apply_changes", is_flag=True, default=False, help="Write changes to files.")
|
|
83
|
+
@click.option("--config", "config_path", default=None, help="Path to contextpilot.yaml.")
|
|
84
|
+
def migrate(path: str, dry_run: bool, apply_changes: bool, config_path: str | None) -> None:
|
|
85
|
+
"""Wrap existing LLM API calls with ContextPilot (Surface D, FR-011).
|
|
86
|
+
|
|
87
|
+
\b
|
|
88
|
+
Scans PATH (file or directory) for OpenAI and Anthropic client
|
|
89
|
+
instantiations and wraps them with contextpilot.wrap():
|
|
90
|
+
|
|
91
|
+
contextpilot migrate ./src/ --dry-run # preview changes
|
|
92
|
+
contextpilot migrate ./src/ --apply # rewrite files
|
|
93
|
+
"""
|
|
94
|
+
from contextpilot.migrate import MigrationAgent
|
|
95
|
+
|
|
96
|
+
# Default to dry-run when neither flag is explicitly given
|
|
97
|
+
effective_dry_run = dry_run or not apply_changes
|
|
98
|
+
|
|
99
|
+
agent = MigrationAgent(config_path=config_path)
|
|
100
|
+
agent.run(path=path, dry_run=effective_dry_run, apply=apply_changes)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
# Report: local savings summary
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
@main.command()
|
|
108
|
+
@click.option("--tail", default=0, help="Show only the last N events (0 = all).")
|
|
109
|
+
def report(tail: int) -> None:
|
|
110
|
+
"""Show token savings from the local event log (~/.contextpilot/events.jsonl)."""
|
|
111
|
+
if not _LOCAL_LOG.exists():
|
|
112
|
+
click.echo("No events recorded yet. Run a few API calls through ContextPilot first.")
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
events: list[dict] = []
|
|
116
|
+
with _LOCAL_LOG.open(encoding="utf-8") as f:
|
|
117
|
+
for line in f:
|
|
118
|
+
line = line.strip()
|
|
119
|
+
if line:
|
|
120
|
+
try:
|
|
121
|
+
events.append(json.loads(line))
|
|
122
|
+
except json.JSONDecodeError:
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
if not events:
|
|
126
|
+
click.echo("Event log is empty.")
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
if tail:
|
|
130
|
+
events = events[-tail:]
|
|
131
|
+
|
|
132
|
+
total = len(events)
|
|
133
|
+
fallbacks = sum(1 for e in events if e.get("fallback_triggered"))
|
|
134
|
+
orig_tokens = sum(e.get("tokens_input_original", 0) for e in events)
|
|
135
|
+
comp_tokens = sum(e.get("tokens_input_compressed", 0) for e in events)
|
|
136
|
+
saved_tokens = orig_tokens - comp_tokens
|
|
137
|
+
avg_quality = sum(e.get("quality_score", 100) for e in events) / total
|
|
138
|
+
|
|
139
|
+
# Dollar savings estimate
|
|
140
|
+
saved_usd = sum(
|
|
141
|
+
(e.get("tokens_input_original", 0) - e.get("tokens_input_compressed", 0))
|
|
142
|
+
/ 1_000_000
|
|
143
|
+
* _rate_for(e.get("model", ""))
|
|
144
|
+
for e in events
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
ratio = (saved_tokens / orig_tokens * 100) if orig_tokens else 0.0
|
|
148
|
+
|
|
149
|
+
click.echo()
|
|
150
|
+
click.echo(" ContextPilot — Savings Report")
|
|
151
|
+
click.echo(" " + "─" * 36)
|
|
152
|
+
click.echo(f" Total calls logged : {total:,}")
|
|
153
|
+
click.echo(f" Fallback rate : {fallbacks}/{total} ({fallbacks/total*100:.1f}%)")
|
|
154
|
+
click.echo(f" Tokens in (original) : {orig_tokens:,}")
|
|
155
|
+
click.echo(f" Tokens in (sent) : {comp_tokens:,}")
|
|
156
|
+
click.echo(f" Tokens saved : {saved_tokens:,} ({ratio:.1f}% reduction)")
|
|
157
|
+
click.echo(f" Avg quality score : {avg_quality:.1f}/100")
|
|
158
|
+
click.echo(f" Est. cost saved : ${saved_usd:.4f}")
|
|
159
|
+
click.echo()
|
|
160
|
+
click.echo(f" Log: {_LOCAL_LOG}")
|
|
161
|
+
click.echo()
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextpilot.analyzer import MessageBlock
|
|
4
|
+
from contextpilot.config import ContextPilotConfig
|
|
5
|
+
from contextpilot.strategies.dedup import SystemPromptDeduplicator
|
|
6
|
+
from contextpilot.strategies.history import summarize_old_turns
|
|
7
|
+
from contextpilot.strategies.rag_pruner import prune_rag_chunks
|
|
8
|
+
from contextpilot.strategies.structural import apply_structural_stripping
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Compressor:
|
|
12
|
+
"""FR-003: Multi-stage compression pipeline.
|
|
13
|
+
|
|
14
|
+
Applies strategies in order:
|
|
15
|
+
1. Conversation history summarization
|
|
16
|
+
2. RAG chunk pruning
|
|
17
|
+
3. Structural formatting stripping
|
|
18
|
+
4. System prompt deduplication (aggressive mode)
|
|
19
|
+
|
|
20
|
+
Each strategy is applied in-place on the message list. The compressor is
|
|
21
|
+
surface-agnostic — the same pipeline runs for the Python library, proxy,
|
|
22
|
+
and MCP server surfaces.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, config: ContextPilotConfig) -> None:
|
|
26
|
+
self.config = config
|
|
27
|
+
self._dedup = SystemPromptDeduplicator()
|
|
28
|
+
|
|
29
|
+
def compress(
|
|
30
|
+
self,
|
|
31
|
+
messages: list[dict],
|
|
32
|
+
blocks: list[MessageBlock],
|
|
33
|
+
system: str | None = None,
|
|
34
|
+
) -> tuple[list[dict], str | None]:
|
|
35
|
+
if not messages:
|
|
36
|
+
return messages, system
|
|
37
|
+
|
|
38
|
+
query = next(
|
|
39
|
+
(m.get("content") or "" for m in reversed(messages) if m.get("role") == "user"),
|
|
40
|
+
"",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
result = list(messages)
|
|
44
|
+
|
|
45
|
+
# Stage 1: history summarization
|
|
46
|
+
result = summarize_old_turns(result, blocks, self.config)
|
|
47
|
+
|
|
48
|
+
# Stage 2: RAG chunk pruning
|
|
49
|
+
if query:
|
|
50
|
+
result = prune_rag_chunks(result, query, self.config)
|
|
51
|
+
|
|
52
|
+
# Stage 3: structural stripping
|
|
53
|
+
result = apply_structural_stripping(result, self.config)
|
|
54
|
+
|
|
55
|
+
# Stage 4: system prompt dedup (aggressive only — changes provider-visible content)
|
|
56
|
+
compressed_system = system
|
|
57
|
+
if system and self.config.compression.level == "aggressive":
|
|
58
|
+
compressed_system = self._dedup.process(system, self.config)
|
|
59
|
+
|
|
60
|
+
return result, compressed_system
|
|
61
|
+
|
|
62
|
+
def reset(self) -> None:
|
|
63
|
+
"""Reset stateful strategies (e.g. between independent sessions)."""
|
|
64
|
+
self._dedup.reset()
|
contextpilot/config.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CompressionConfig(BaseModel):
|
|
12
|
+
level: str = "balanced" # conservative | balanced | aggressive
|
|
13
|
+
quality_threshold: float = 85.0 # fallback below this score
|
|
14
|
+
history_window: int = 6 # keep last N turns verbatim
|
|
15
|
+
rag_relevance_min: float = 0.15 # drop RAG chunks below this TF-IDF score
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ShadowTestingConfig(BaseModel):
|
|
19
|
+
enabled: bool = False
|
|
20
|
+
sample_rate: float = 0.05 # fraction of calls shadowed
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TelemetryConfig(BaseModel):
|
|
24
|
+
enabled: bool = True
|
|
25
|
+
endpoint: str = "https://api.contextpilot.dev/v1/telemetry"
|
|
26
|
+
api_key: Optional[str] = None
|
|
27
|
+
flush_interval: int = 60 # seconds
|
|
28
|
+
flush_size: int = 100 # events
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ContextPilotConfig(BaseModel):
|
|
32
|
+
compression: CompressionConfig = Field(default_factory=CompressionConfig)
|
|
33
|
+
shadow_testing: ShadowTestingConfig = Field(default_factory=ShadowTestingConfig)
|
|
34
|
+
telemetry: TelemetryConfig = Field(default_factory=TelemetryConfig)
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def load(cls, path: str | Path | None = None) -> "ContextPilotConfig":
|
|
38
|
+
"""Load config from YAML file, then apply environment variable overrides."""
|
|
39
|
+
if path is None:
|
|
40
|
+
for candidate in ("contextpilot.yaml", "contextpilot.yml"):
|
|
41
|
+
if Path(candidate).exists():
|
|
42
|
+
path = candidate
|
|
43
|
+
break
|
|
44
|
+
|
|
45
|
+
data: dict = {}
|
|
46
|
+
if path and Path(str(path)).exists():
|
|
47
|
+
with open(path) as f:
|
|
48
|
+
data = yaml.safe_load(f) or {}
|
|
49
|
+
|
|
50
|
+
comp = data.setdefault("compression", {})
|
|
51
|
+
if val := os.getenv("CONTEXTPILOT_QUALITY_THRESHOLD"):
|
|
52
|
+
comp["quality_threshold"] = float(val)
|
|
53
|
+
if val := os.getenv("CONTEXTPILOT_COMPRESSION_LEVEL"):
|
|
54
|
+
comp["level"] = val
|
|
55
|
+
if val := os.getenv("CONTEXTPILOT_HISTORY_WINDOW"):
|
|
56
|
+
comp["history_window"] = int(val)
|
|
57
|
+
|
|
58
|
+
tele = data.setdefault("telemetry", {})
|
|
59
|
+
if val := os.getenv("CONTEXTPILOT_API_KEY"):
|
|
60
|
+
tele["api_key"] = val
|
|
61
|
+
if val := os.getenv("CONTEXTPILOT_TELEMETRY_ENDPOINT"):
|
|
62
|
+
tele["endpoint"] = val
|
|
63
|
+
|
|
64
|
+
return cls.model_validate(data)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextpilot.config import ContextPilotConfig
|
|
4
|
+
from contextpilot.strategies.agent_memory import compress_agent_handoff
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AgentMemory:
|
|
8
|
+
"""FR-008: Agent memory middleware for LangChain / CrewAI / AutoGen.
|
|
9
|
+
|
|
10
|
+
Compresses inter-agent context handoffs, targeting 70–90% token reduction
|
|
11
|
+
in agentic workflows (technical doc §3.5).
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
from contextpilot.middleware import AgentMemory
|
|
15
|
+
memory = AgentMemory(compression_level="aggressive",
|
|
16
|
+
preserve_keys=["final_answer", "tool_outputs"])
|
|
17
|
+
compressed = memory.compress_handoff(agent_a_output)
|
|
18
|
+
agent_b_output = agent_b.run(task, context=compressed)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
compression_level: str = "balanced",
|
|
24
|
+
preserve_keys: list[str] | None = None,
|
|
25
|
+
config: ContextPilotConfig | None = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
if config is None:
|
|
28
|
+
cfg_data = {"compression": {"level": compression_level}}
|
|
29
|
+
self._config = ContextPilotConfig.model_validate(cfg_data)
|
|
30
|
+
else:
|
|
31
|
+
self._config = config
|
|
32
|
+
self._preserve_keys = preserve_keys or []
|
|
33
|
+
|
|
34
|
+
def compress_handoff(self, agent_output: str) -> str:
|
|
35
|
+
"""Compress an agent's output before passing it to the next agent."""
|
|
36
|
+
return compress_agent_handoff(agent_output, self._preserve_keys, self._config)
|