contextpress 0.1.0__tar.gz → 0.2.0__tar.gz

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 (44) hide show
  1. contextpress-0.2.0/CHANGELOG.md +17 -0
  2. contextpress-0.2.0/CONTRIBUTING.md +61 -0
  3. {contextpress-0.1.0 → contextpress-0.2.0}/PKG-INFO +9 -1
  4. {contextpress-0.1.0 → contextpress-0.2.0}/README.md +8 -0
  5. {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/__init__.py +10 -3
  6. {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/compression.py +1 -1
  7. {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/core.py +14 -5
  8. contextpress-0.2.0/contextpress/llm/_helpers.py +57 -0
  9. {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/llm/adapters.py +53 -4
  10. {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/models.py +3 -1
  11. {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/normalizer.py +25 -8
  12. {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/pipeline.py +49 -9
  13. contextpress-0.2.0/contextpress/stats.py +67 -0
  14. {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/strategies/filler.py +1 -1
  15. {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/strategies/recency.py +4 -2
  16. {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/strategies/repetition.py +3 -1
  17. {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/strategies/resolution.py +3 -2
  18. {contextpress-0.1.0 → contextpress-0.2.0}/examples/llm_tier_openai.py +1 -1
  19. {contextpress-0.1.0 → contextpress-0.2.0}/pyproject.toml +13 -2
  20. {contextpress-0.1.0 → contextpress-0.2.0}/tests/test_filler.py +1 -1
  21. contextpress-0.2.0/tests/test_llm_helpers.py +65 -0
  22. {contextpress-0.1.0 → contextpress-0.2.0}/tests/test_models.py +2 -2
  23. {contextpress-0.1.0 → contextpress-0.2.0}/tests/test_pipeline.py +8 -4
  24. contextpress-0.2.0/tests/test_stats.py +40 -0
  25. contextpress-0.1.0/CHANGELOG.md +0 -10
  26. {contextpress-0.1.0 → contextpress-0.2.0}/.gitignore +0 -0
  27. {contextpress-0.1.0 → contextpress-0.2.0}/CITATION.cff +0 -0
  28. {contextpress-0.1.0 → contextpress-0.2.0}/LICENSE +0 -0
  29. {contextpress-0.1.0 → contextpress-0.2.0}/NOTICE +0 -0
  30. {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/_bootstrap.py +0 -0
  31. {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/llm/__init__.py +0 -0
  32. {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/llm/base.py +0 -0
  33. {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/profiles.py +0 -0
  34. {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/py.typed +0 -0
  35. {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/strategies/__init__.py +0 -0
  36. {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/strategies/base.py +0 -0
  37. {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/strategies/budget.py +0 -0
  38. {contextpress-0.1.0 → contextpress-0.2.0}/examples/llm_tier_ollama.py +0 -0
  39. {contextpress-0.1.0 → contextpress-0.2.0}/tests/__init__.py +0 -0
  40. {contextpress-0.1.0 → contextpress-0.2.0}/tests/test_budget.py +0 -0
  41. {contextpress-0.1.0 → contextpress-0.2.0}/tests/test_normalizer.py +0 -0
  42. {contextpress-0.1.0 → contextpress-0.2.0}/tests/test_recency.py +0 -0
  43. {contextpress-0.1.0 → contextpress-0.2.0}/tests/test_repetition.py +0 -0
  44. {contextpress-0.1.0 → contextpress-0.2.0}/tests/test_resolution.py +0 -0
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ All notable changes to `contextpress` are recorded here.
4
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
5
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [0.2.0] - 2026-05-24
8
+
9
+ - **`return_stats=True`** on `ContextManager.compress()` returns a `CompressionResult` with token/turn counts, stages run, and per-stage turn deltas.
10
+ - **Tier 2 deduplication** implemented in OpenAI, Anthropic, and Ollama adapters (LLM selects indices to keep).
11
+ - **GitHub Actions CI** runs `pytest` on Python 3.10–3.13.
12
+ - Added **`CONTRIBUTING.md`** with contribution and review expectations.
13
+
14
+ ## [0.1.0] - 2026-04-19
15
+
16
+ - Initial release by Taha Azizi.
17
+ - Tier 1 pipeline (filler, repetition, resolution, recency, budget) and optional Tier 2 `LLMBackend` (OpenAI, Anthropic, Ollama adapters).
@@ -0,0 +1,61 @@
1
+ # Contributing to contextpress
2
+
3
+ Thank you for your interest in contributing. This project is maintained by [Taha Azizi](https://github.com/Taha-azizi) at a **low cadence**. Bug fixes are welcome; large feature work may be better suited to a fork if you need it quickly.
4
+
5
+ ## Before you start
6
+
7
+ - Read the [Project Status](README.md#project-status) section in the README.
8
+ - Search [existing issues](https://github.com/Taha-azizi/contextpress/issues) before opening a duplicate.
9
+ - Expect a **2–4 week** review cycle for pull requests.
10
+
11
+ ## Branches
12
+
13
+ | Branch | Purpose |
14
+ |--------|---------|
15
+ | **`dev`** | Active development — **branch from here, open PRs here** |
16
+ | **`main`** | Stable releases (merged from `dev` at release time) |
17
+
18
+ After cloning:
19
+
20
+ ```bash
21
+ git checkout dev
22
+ ```
23
+
24
+ ## How to contribute
25
+
26
+ 1. **Fork** the repository and create a feature branch from **`dev`** (not `main`).
27
+ 2. **Set up** a local environment:
28
+ ```bash
29
+ pip install -e ".[dev]"
30
+ pytest tests -q
31
+ ```
32
+ 3. **Make focused changes** — one logical change per PR when possible.
33
+ 4. **Add or update tests** for behavior you change. Tier 1 tests must stay deterministic (no live LLM calls in `tests/`).
34
+ 5. **Open a pull request** targeting **`dev`** with:
35
+ - A short summary of *why* the change is needed
36
+ - How you tested it (`pytest tests -q`, etc.)
37
+ - A note if you changed public API (update `CHANGELOG.md`)
38
+
39
+ ## Code guidelines
40
+
41
+ - Match existing style in the file you edit.
42
+ - Do not break the [behavior contract](contextpress/pipeline.py) at the top of `pipeline.py`.
43
+ - System turns stay protected; input is never mutated in place.
44
+ - Keep Tier 1 deterministic — mock LLM backends in tests.
45
+
46
+ ## Reporting bugs
47
+
48
+ Open a [GitHub issue](https://github.com/Taha-azizi/contextpress/issues) with:
49
+
50
+ - Python version
51
+ - `contextpress` version (`pip show contextpress`)
52
+ - Minimal input that reproduces the problem
53
+ - Expected vs actual behavior
54
+
55
+ ## Feature requests
56
+
57
+ Feature requests may be accepted, deferred, or closed with a suggestion to fork. That is not a rejection of the idea — it reflects limited maintenance bandwidth.
58
+
59
+ ## License
60
+
61
+ By contributing, you agree that your contributions are licensed under the [Apache License 2.0](LICENSE).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: contextpress
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Deterministic context compression for LLM chat, RAG, and agent pipelines
5
5
  Project-URL: Homepage, https://github.com/Taha-azizi/contextpress
6
6
  Project-URL: Documentation, https://github.com/Taha-azizi/contextpress#readme
@@ -282,6 +282,14 @@ compressed = cm.compress(messages, token_budget=2000)
282
282
 
283
283
  No API keys are required for Tier 1. Passing **`token_budget`** turns on the **budget** stage; other stages follow the chosen **compression** preset (`low` / `medium` / `high`).
284
284
 
285
+ Pass **`return_stats=True`** to get a `CompressionResult` with `messages` and compression stats (token counts, stages run, turn deltas):
286
+
287
+ ```python
288
+ result = cm.compress(messages, token_budget=2000, return_stats=True)
289
+ print(result.stats.tokens_saved, result.stats.stages_run)
290
+ compressed = result.messages
291
+ ```
292
+
285
293
  ### Minimal examples
286
294
 
287
295
  ```python
@@ -42,6 +42,14 @@ compressed = cm.compress(messages, token_budget=2000)
42
42
 
43
43
  No API keys are required for Tier 1. Passing **`token_budget`** turns on the **budget** stage; other stages follow the chosen **compression** preset (`low` / `medium` / `high`).
44
44
 
45
+ Pass **`return_stats=True`** to get a `CompressionResult` with `messages` and compression stats (token counts, stages run, turn deltas):
46
+
47
+ ```python
48
+ result = cm.compress(messages, token_budget=2000, return_stats=True)
49
+ print(result.stats.tokens_saved, result.stats.stages_run)
50
+ compressed = result.messages
51
+ ```
52
+
45
53
  ### Minimal examples
46
54
 
47
55
  ```python
@@ -4,15 +4,22 @@ from typing import Any
4
4
 
5
5
  from contextpress._bootstrap import bootstrap_nltk
6
6
  from contextpress.models import ContentBlock, Conversation, Turn
7
+ from contextpress.stats import CompressionResult, CompressionStats
7
8
 
8
9
  bootstrap_nltk()
9
10
 
10
- __all__ = ["ContextManager", "Turn", "Conversation", "ContentBlock"]
11
- __version__ = "0.1.0"
11
+ __all__ = [
12
+ "ContextManager",
13
+ "Turn",
14
+ "Conversation",
15
+ "ContentBlock",
16
+ "CompressionResult",
17
+ "CompressionStats",
18
+ ]
19
+ __version__ = "0.2.0"
12
20
 
13
21
 
14
22
  def __getattr__(name: str) -> Any:
15
- # Lazy import: ContextManager -> core -> pipeline -> strategies (sumy, etc.)
16
23
  if name == "ContextManager":
17
24
  from contextpress.core import ContextManager
18
25
 
@@ -98,4 +98,4 @@ def apply_stage_selection(
98
98
  if disable and "budget" in disable:
99
99
  profile.budget.enabled = False
100
100
  else:
101
- profile.budget.enabled = getattr(base_profile, "budget").enabled
101
+ profile.budget.enabled = base_profile.budget.enabled
@@ -8,6 +8,7 @@ from contextpress.compression import apply_stage_selection, normalize_compressio
8
8
  from contextpress.normalizer import denormalize_output, normalize_messages
9
9
  from contextpress.pipeline import Pipeline
10
10
  from contextpress.profiles import PROFILES, Profile, StageConfig
11
+ from contextpress.stats import CompressionResult, CompressionStats
11
12
 
12
13
  if TYPE_CHECKING:
13
14
  from contextpress.llm.base import LLMBackend
@@ -57,23 +58,27 @@ class ContextManager:
57
58
  compression: str | None = None,
58
59
  stages: list[str] | None = None,
59
60
  disable: list[str] | None = None,
60
- ) -> Any:
61
+ return_stats: bool = False,
62
+ ) -> Any | CompressionResult:
61
63
  """Run the pipeline; return value matches input shape (dict list, tuples, strings, etc.).
62
64
 
63
65
  ``token_budget`` must be a positive int or None. Unknown keys in ``disable`` are ignored.
66
+ With ``return_stats=True``, returns a ``CompressionResult`` with ``messages`` and ``stats``.
64
67
  """
65
68
  _validate_token_budget(token_budget)
69
+ level = compression if compression is not None else self._compression
66
70
  profile = copy.deepcopy(self._profile)
67
71
  apply_stage_selection(
68
72
  profile,
69
73
  base_profile=self._profile,
70
- compression=compression if compression is not None else self._compression,
74
+ compression=level,
71
75
  stages=stages,
72
76
  disable=disable,
73
77
  token_budget=token_budget,
74
78
  )
75
79
 
76
80
  conv, ctx = normalize_messages(messages, context_type=self._type)
81
+ stats = CompressionStats(compression_level=level) if return_stats else None
77
82
  pipeline = Pipeline(
78
83
  profile,
79
84
  token_budget=token_budget,
@@ -82,15 +87,19 @@ class ContextManager:
82
87
  llm_min_input_chars=self.llm_min_input_chars,
83
88
  llm_max_summary_tokens=self.llm_max_summary_tokens,
84
89
  )
85
- out = pipeline.run(conv)
86
- return denormalize_output(out, ctx)
90
+ out = pipeline.run(conv, stats=stats)
91
+ messages_out = denormalize_output(out, ctx)
92
+ if return_stats:
93
+ assert stats is not None
94
+ return CompressionResult(messages=messages_out, stats=stats)
95
+ return messages_out
87
96
 
88
97
  def set_compression(self, compression: str) -> None:
89
98
  """Change the default preset for subsequent ``compress()`` calls (low / medium / high)."""
90
99
  self._compression = normalize_compression_level(compression)
91
100
 
92
101
  def configure(self, stage: str, **kwargs: Any) -> None:
93
- """Patch ``StageConfig`` fields on the live profile (e.g. ``aggressiveness``, ``enabled``)."""
102
+ """Patch ``StageConfig`` fields on the live profile (e.g. aggressiveness, enabled)."""
94
103
  if not hasattr(self._profile, stage):
95
104
  raise ValueError(f"unknown stage {stage!r}")
96
105
  sc: StageConfig = getattr(self._profile, stage)
@@ -0,0 +1,57 @@
1
+ """Shared helpers for Tier 2 LLM adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+
8
+
9
+ def parse_keep_indices(raw: str, n: int) -> list[int]:
10
+ """Parse an LLM response into sorted unique turn indices to keep."""
11
+ if n <= 0:
12
+ return []
13
+ if n == 1:
14
+ return [0]
15
+
16
+ text = (raw or "").strip()
17
+ if not text:
18
+ return list(range(n))
19
+
20
+ # JSON array: [0, 2, 4]
21
+ if text.startswith("["):
22
+ try:
23
+ data = json.loads(text)
24
+ if isinstance(data, list):
25
+ return _validate_indices(data, n)
26
+ except json.JSONDecodeError:
27
+ pass
28
+
29
+ # Comma / space separated: 0, 2, 4
30
+ nums = re.findall(r"\d+", text)
31
+ if nums:
32
+ return _validate_indices([int(x) for x in nums], n)
33
+
34
+ return list(range(n))
35
+
36
+
37
+ def _validate_indices(values: list[int], n: int) -> list[int]:
38
+ valid = sorted({i for i in values if isinstance(i, int) and 0 <= i < n})
39
+ return valid if valid else list(range(n))
40
+
41
+
42
+ def format_numbered_turns(turns: list[str], *, max_chars: int = 800) -> str:
43
+ lines: list[str] = []
44
+ for i, text in enumerate(turns):
45
+ snippet = text.strip().replace("\n", " ")
46
+ if len(snippet) > max_chars:
47
+ snippet = snippet[: max_chars - 3] + "..."
48
+ lines.append(f"{i}: {snippet}")
49
+ return "\n".join(lines)
50
+
51
+
52
+ DEDUP_SYSTEM_PROMPT = (
53
+ "You deduplicate conversation turns. Given numbered turns, return ONLY the 0-based "
54
+ "indices of turns to KEEP as a comma-separated list (example: 0,2,5). "
55
+ "When turns repeat the same idea, keep the most recent index. "
56
+ "Always keep at least one turn. Output nothing else."
57
+ )
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  import warnings
4
4
  from typing import Any
5
5
 
6
-
6
+ from contextpress.llm._helpers import DEDUP_SYSTEM_PROMPT, format_numbered_turns, parse_keep_indices
7
7
  from contextpress.llm.base import LLMBackend
8
8
 
9
9
 
@@ -60,7 +60,24 @@ class OpenAIBackend(LLMBackend):
60
60
  raise
61
61
 
62
62
  def deduplicate(self, turns: list[str]) -> list[int]:
63
- return list(range(len(turns)))
63
+ if len(turns) <= 1:
64
+ return list(range(len(turns)))
65
+ prompt = format_numbered_turns(turns)
66
+ try:
67
+ resp = self.client.chat.completions.create(
68
+ model=self.model,
69
+ messages=[
70
+ {"role": "system", "content": DEDUP_SYSTEM_PROMPT},
71
+ {"role": "user", "content": prompt},
72
+ ],
73
+ max_tokens=128,
74
+ temperature=0,
75
+ )
76
+ content = resp.choices[0].message.content or ""
77
+ return parse_keep_indices(content, len(turns))
78
+ except Exception as exc:
79
+ warnings.warn(f"contextpress OpenAIBackend.deduplicate failed: {exc}", stacklevel=2)
80
+ return list(range(len(turns)))
64
81
 
65
82
 
66
83
  class AnthropicBackend(LLMBackend):
@@ -98,7 +115,24 @@ class AnthropicBackend(LLMBackend):
98
115
  raise
99
116
 
100
117
  def deduplicate(self, turns: list[str]) -> list[int]:
101
- return list(range(len(turns)))
118
+ if len(turns) <= 1:
119
+ return list(range(len(turns)))
120
+ prompt = format_numbered_turns(turns)
121
+ try:
122
+ msg = self.client.messages.create(
123
+ model=self.model,
124
+ max_tokens=128,
125
+ system=DEDUP_SYSTEM_PROMPT,
126
+ messages=[{"role": "user", "content": prompt}],
127
+ )
128
+ parts = []
129
+ for b in msg.content:
130
+ if hasattr(b, "text"):
131
+ parts.append(b.text)
132
+ return parse_keep_indices("".join(parts), len(turns))
133
+ except Exception as exc:
134
+ warnings.warn(f"contextpress AnthropicBackend.deduplicate failed: {exc}", stacklevel=2)
135
+ return list(range(len(turns)))
102
136
 
103
137
 
104
138
  class OllamaBackend(LLMBackend):
@@ -171,4 +205,19 @@ class OllamaBackend(LLMBackend):
171
205
  raise
172
206
 
173
207
  def deduplicate(self, turns: list[str]) -> list[int]:
174
- return list(range(len(turns)))
208
+ if len(turns) <= 1:
209
+ return list(range(len(turns)))
210
+ prompt = format_numbered_turns(turns)
211
+ try:
212
+ resp = self._client.chat(
213
+ model=self.model,
214
+ messages=[
215
+ {"role": "system", "content": DEDUP_SYSTEM_PROMPT},
216
+ {"role": "user", "content": prompt},
217
+ ],
218
+ options={"num_predict": 128, "temperature": 0},
219
+ )
220
+ return parse_keep_indices(_ollama_response_text(resp), len(turns))
221
+ except Exception as exc:
222
+ warnings.warn(f"contextpress OllamaBackend.deduplicate failed: {exc}", stacklevel=2)
223
+ return list(range(len(turns)))
@@ -32,7 +32,9 @@ class Turn:
32
32
  importance: float = 1.0 # set by pipeline stages, 0.0–1.0
33
33
  resolved: bool = False # True when flagged by resolution detector
34
34
  compressed: bool = False # True if content was modified by pipeline
35
- original_content: str | list[ContentBlock] | None = None # preserves original before compression
35
+ original_content: str | list[ContentBlock] | None = (
36
+ None # preserves original before compression
37
+ )
36
38
 
37
39
 
38
40
  @dataclass
@@ -52,8 +52,14 @@ def _blocks_from_openai_style(items: list[dict[str, Any]]) -> list[ContentBlock]
52
52
  url = ""
53
53
  if "image_url" in item:
54
54
  iu = item["image_url"]
55
- url = iu if isinstance(iu, str) else (iu.get("url", "") if isinstance(iu, dict) else "")
56
- blocks.append(ContentBlock(type="image", content=url or str(item), metadata=copy.deepcopy(item)))
55
+ url = (
56
+ iu
57
+ if isinstance(iu, str)
58
+ else (iu.get("url", "") if isinstance(iu, dict) else "")
59
+ )
60
+ blocks.append(
61
+ ContentBlock(type="image", content=url or str(item), metadata=copy.deepcopy(item))
62
+ )
57
63
  else:
58
64
  blocks.append(
59
65
  ContentBlock(
@@ -83,12 +89,13 @@ def _blocks_to_openai_style(blocks: list[ContentBlock]) -> list[dict[str, Any]]:
83
89
  else:
84
90
  out.append({"type": "image_url", "image_url": {"url": b.content}})
85
91
  else:
86
- out.append(copy.deepcopy(b.metadata) if b.metadata else {"type": b.type, "content": b.content})
92
+ out.append(
93
+ copy.deepcopy(b.metadata) if b.metadata else {"type": b.type, "content": b.content}
94
+ )
87
95
  return out
88
96
 
89
97
 
90
98
  def _get_lc_role_and_content(obj: Any) -> tuple[str, str | list[ContentBlock], dict[str, Any]]:
91
- meta: dict[str, Any] = {}
92
99
  role = None
93
100
  if hasattr(obj, "type") and obj.type is not None:
94
101
  role = _LC_TYPE_MAP.get(str(obj.type).lower(), str(obj.type).lower())
@@ -101,7 +108,11 @@ def _get_lc_role_and_content(obj: Any) -> tuple[str, str | list[ContentBlock], d
101
108
  blocks = []
102
109
  for part in content:
103
110
  if isinstance(part, dict) and part.get("type") == "text":
104
- blocks.append(ContentBlock(type="text", content=part.get("text", ""), metadata=copy.deepcopy(part)))
111
+ blocks.append(
112
+ ContentBlock(
113
+ type="text", content=part.get("text", ""), metadata=copy.deepcopy(part)
114
+ )
115
+ )
105
116
  elif isinstance(part, str):
106
117
  blocks.append(ContentBlock(type="text", content=part))
107
118
  else:
@@ -176,7 +187,9 @@ def normalize_messages(
176
187
  turns = []
177
188
  for i, raw in enumerate(messages):
178
189
  if not isinstance(raw, dict):
179
- warnings.warn(f"contextpress: unknown message shape at index {i}, skipping", stacklevel=2)
190
+ warnings.warn(
191
+ f"contextpress: unknown message shape at index {i}, skipping", stacklevel=2
192
+ )
180
193
  continue
181
194
  d = copy.deepcopy(raw)
182
195
  role = str(d.get("role", "user")).lower()
@@ -292,7 +305,9 @@ def apply_text_to_turn(turn: Turn, new_text: str) -> Turn:
292
305
  importance=turn.importance,
293
306
  resolved=turn.resolved,
294
307
  compressed=True,
295
- original_content=turn.original_content if turn.original_content is not None else turn.content,
308
+ original_content=(
309
+ turn.original_content if turn.original_content is not None else turn.content
310
+ ),
296
311
  )
297
312
  new_blocks: list[ContentBlock] = []
298
313
  text_assigned = False
@@ -304,7 +319,9 @@ def apply_text_to_turn(turn: Turn, new_text: str) -> Turn:
304
319
  new_blocks.append(copy.deepcopy(b))
305
320
  if not text_assigned:
306
321
  new_blocks.insert(0, ContentBlock(type="text", content=new_text))
307
- orig = turn.original_content if turn.original_content is not None else copy.deepcopy(turn.content)
322
+ orig = (
323
+ turn.original_content if turn.original_content is not None else copy.deepcopy(turn.content)
324
+ )
308
325
  return Turn(
309
326
  role=turn.role,
310
327
  content=new_blocks,
@@ -11,8 +11,10 @@ CONTEXTPRESS BEHAVIOR CONTRACT
11
11
  8. Tier 1 (no LLM) behavior is ALWAYS deterministic. Tests must pass consistently.
12
12
  9. LLM backend failures fall back to Tier 1 and emit a warning.
13
13
  10. token_budget=None means run all stages but skip budget enforcement.
14
- 11. Compression presets (low/medium/high) select non-budget stages; budget runs when token_budget is set unless opted out.
15
- 12. Tier 2 (when enabled) may dedupe non-system turns, then replace them with one assistant summary; system turns stay unchanged.
14
+ 11. Compression presets (low/medium/high) select non-budget stages; budget runs when
15
+ token_budget is set unless opted out.
16
+ 12. Tier 2 (when enabled) may dedupe non-system turns, then replace them with one assistant
17
+ summary; system turns stay unchanged.
16
18
  """
17
19
 
18
20
  from __future__ import annotations
@@ -25,6 +27,7 @@ from contextpress.compression import STAGE_ORDER
25
27
  from contextpress.models import Conversation, Turn
26
28
  from contextpress.normalizer import extract_text_for_processing
27
29
  from contextpress.profiles import Profile, StageConfig
30
+ from contextpress.stats import CompressionStats, count_conversation_tokens
28
31
  from contextpress.strategies.base import BaseStrategy
29
32
  from contextpress.strategies.budget import BudgetStrategy
30
33
  from contextpress.strategies.filler import FillerStrategy
@@ -49,9 +52,9 @@ def clone_turn(t: Turn) -> Turn:
49
52
  importance=t.importance,
50
53
  resolved=t.resolved,
51
54
  compressed=t.compressed,
52
- original_content=copy.deepcopy(t.original_content)
53
- if t.original_content is not None
54
- else None,
55
+ original_content=(
56
+ copy.deepcopy(t.original_content) if t.original_content is not None else None
57
+ ),
55
58
  )
56
59
 
57
60
 
@@ -85,7 +88,17 @@ class Pipeline:
85
88
  self.llm_min_input_chars = max(0, int(llm_min_input_chars))
86
89
  self.llm_max_summary_tokens = max(64, int(llm_max_summary_tokens))
87
90
 
88
- def run(self, conversation: Conversation) -> Conversation:
91
+ def run(
92
+ self,
93
+ conversation: Conversation,
94
+ stats: CompressionStats | None = None,
95
+ ) -> Conversation:
96
+ if stats is not None:
97
+ stats.turns_before = len(conversation.turns)
98
+ stats.tokens_before = count_conversation_tokens(conversation, self.model)
99
+ stats.context_type = conversation.type
100
+ stats.token_budget = self.token_budget
101
+
89
102
  result = clone_conversation(conversation)
90
103
  for stage_name in self.STAGE_ORDER:
91
104
  if stage_name == "budget" and self.token_budget is None:
@@ -93,11 +106,21 @@ class Pipeline:
93
106
  stage_config = getattr(self.profile, stage_name)
94
107
  if not stage_config.enabled:
95
108
  continue
109
+ before_turns = len(result.turns)
96
110
  strategy = self._build_strategy(stage_name, stage_config)
97
111
  result = strategy.process(result)
112
+ if stats is not None:
113
+ stats.stages_run.append(stage_name)
114
+ delta = len(result.turns) - before_turns
115
+ if delta != 0:
116
+ stats.turn_delta_by_stage[stage_name] = delta
98
117
 
99
118
  if self.llm_backend is not None:
100
- result = self._run_llm_tier(result)
119
+ result = self._run_llm_tier(result, stats=stats)
120
+
121
+ if stats is not None:
122
+ stats.turns_after = len(result.turns)
123
+ stats.tokens_after = count_conversation_tokens(result, self.model)
101
124
 
102
125
  return result
103
126
 
@@ -125,7 +148,11 @@ class Pipeline:
125
148
  )
126
149
  raise ValueError(f"unknown stage {name!r}")
127
150
 
128
- def _run_llm_tier(self, conversation: Conversation) -> Conversation:
151
+ def _run_llm_tier(
152
+ self,
153
+ conversation: Conversation,
154
+ stats: CompressionStats | None = None,
155
+ ) -> Conversation:
129
156
  if self.llm_backend is None:
130
157
  return conversation
131
158
 
@@ -135,13 +162,20 @@ class Pipeline:
135
162
  if not ns_turns:
136
163
  return conversation
137
164
 
165
+ if stats is not None:
166
+ stats.llm_dedup_turns_before = len(ns_turns)
167
+
138
168
  texts = [extract_text_for_processing(t) for t in ns_turns]
139
169
  try:
140
170
  keep_idx = self.llm_backend.deduplicate(texts)
141
171
  except Exception:
142
172
  keep_idx = list(range(len(ns_turns)))
143
173
  valid = sorted(
144
- {i for i in keep_idx if type(i) is int and not isinstance(i, bool) and 0 <= i < len(ns_turns)}
174
+ {
175
+ i
176
+ for i in keep_idx
177
+ if type(i) is int and not isinstance(i, bool) and 0 <= i < len(ns_turns)
178
+ }
145
179
  )
146
180
  if not valid:
147
181
  valid = list(range(len(ns_turns)))
@@ -149,6 +183,9 @@ class Pipeline:
149
183
  ns_turns = [ns_turns[i] for i in valid]
150
184
  texts = [texts[i] for i in valid]
151
185
 
186
+ if stats is not None:
187
+ stats.llm_dedup_turns_after = len(ns_turns)
188
+
152
189
  lines = [f"{t.role}: {txt}" for t, txt in zip(ns_turns, texts, strict=True)]
153
190
  combined = "\n\n".join(lines)
154
191
  if self.llm_min_input_chars > 0 and len(combined) < self.llm_min_input_chars:
@@ -167,6 +204,9 @@ class Pipeline:
167
204
  if not summary:
168
205
  return conversation
169
206
 
207
+ if stats is not None:
208
+ stats.llm_tier_applied = True
209
+
170
210
  new_turns: list[Turn] = list(system_turns)
171
211
  new_turns.append(
172
212
  Turn(
@@ -0,0 +1,67 @@
1
+ """Compression statistics returned when ``return_stats=True``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+ import tiktoken
9
+
10
+ from contextpress.models import Conversation, Turn
11
+ from contextpress.normalizer import extract_text_for_processing
12
+
13
+
14
+ def get_encoding(model: str | None) -> tiktoken.Encoding:
15
+ if model:
16
+ try:
17
+ return tiktoken.encoding_for_model(model)
18
+ except KeyError:
19
+ pass
20
+ return tiktoken.get_encoding("cl100k_base")
21
+
22
+
23
+ def count_turn_tokens(turn: Turn, encoding: tiktoken.Encoding) -> int:
24
+ if isinstance(turn.content, str):
25
+ body = turn.content
26
+ else:
27
+ body = extract_text_for_processing(turn)
28
+ return len(encoding.encode(f"{turn.role}\n{body}"))
29
+
30
+
31
+ def count_conversation_tokens(conversation: Conversation, model: str | None) -> int:
32
+ enc = get_encoding(model)
33
+ return sum(count_turn_tokens(t, enc) for t in conversation.turns)
34
+
35
+
36
+ @dataclass
37
+ class CompressionStats:
38
+ """Observed effects of a single ``compress()`` call."""
39
+
40
+ turns_before: int = 0
41
+ turns_after: int = 0
42
+ tokens_before: int = 0
43
+ tokens_after: int = 0
44
+ stages_run: list[str] = field(default_factory=list)
45
+ turn_delta_by_stage: dict[str, int] = field(default_factory=dict)
46
+ llm_tier_applied: bool = False
47
+ llm_dedup_turns_before: int = 0
48
+ llm_dedup_turns_after: int = 0
49
+ compression_level: str | None = None
50
+ context_type: str = "chat"
51
+ token_budget: int | None = None
52
+
53
+ @property
54
+ def turns_removed(self) -> int:
55
+ return max(0, self.turns_before - self.turns_after)
56
+
57
+ @property
58
+ def tokens_saved(self) -> int:
59
+ return max(0, self.tokens_before - self.tokens_after)
60
+
61
+
62
+ @dataclass
63
+ class CompressionResult:
64
+ """Messages plus stats when ``return_stats=True``."""
65
+
66
+ messages: Any
67
+ stats: CompressionStats
@@ -94,7 +94,7 @@ def _build_filler_pattern() -> re.Pattern[str]:
94
94
 
95
95
  _FILLER_RE = _build_filler_pattern()
96
96
 
97
- # "actually" at start of "actually, no" keep if followed by comma and negation? Spec: "actually, no"
97
+ # Keep "actually" when it starts a phrase like "actually, no" (per spec).
98
98
  _ACTUALLY_NO = re.compile(r"^\s*actually\s*,\s*no\b", re.IGNORECASE)
99
99
 
100
100
 
@@ -114,14 +114,16 @@ class RecencyStrategy(BaseStrategy):
114
114
  else:
115
115
  processed_by_ns.append(copy.deepcopy(t))
116
116
 
117
- by_idx = dict(zip(ns_indices, processed_by_ns))
117
+ by_idx = dict(zip(ns_indices, processed_by_ns, strict=True))
118
118
  out: list[Turn] = []
119
119
  for i, t in enumerate(conversation.turns):
120
120
  if i in by_idx:
121
121
  out.append(by_idx[i])
122
122
  else:
123
123
  out.append(copy.deepcopy(t))
124
- return Conversation(turns=out, type=conversation.type, metadata=copy.deepcopy(conversation.metadata))
124
+ return Conversation(
125
+ turns=out, type=conversation.type, metadata=copy.deepcopy(conversation.metadata)
126
+ )
125
127
 
126
128
  def _relevance_score(self, query: str, chunk: str) -> float:
127
129
  if not query.strip() or not chunk.strip():
@@ -38,7 +38,9 @@ class RepetitionStrategy(BaseStrategy):
38
38
  indices_to_drop: set[int] = set()
39
39
 
40
40
  # Positions of non-system turns with their global index
41
- nst: list[tuple[int, Turn]] = [(i, t) for i, t in enumerate(turns) if not self._is_protected(t)]
41
+ nst: list[tuple[int, Turn]] = [
42
+ (i, t) for i, t in enumerate(turns) if not self._is_protected(t)
43
+ ]
42
44
 
43
45
  def run_group(group: list[tuple[int, Turn]]) -> None:
44
46
  if len(group) < 2:
@@ -166,14 +166,15 @@ class ResolutionStrategy(BaseStrategy):
166
166
  res_text = extract_text_for_processing(turns[res_idx])
167
167
 
168
168
  if self.conv_type == "chat":
169
- # Include one trailing turn so an assistant acknowledgement after a user's resolution still counts
169
+ # Include one trailing turn so an assistant ack after a user's resolution still counts.
170
170
  scan_end = min(len(turns) - 1, res_idx + 1)
171
171
  has_user = any(
172
172
  turns[i].role == "user" and _has_signal_any(extract_text_for_processing(turns[i]))
173
173
  for i in range(thread_start, scan_end + 1)
174
174
  )
175
175
  has_asst = any(
176
- turns[i].role == "assistant" and _has_signal_any(extract_text_for_processing(turns[i]))
176
+ turns[i].role == "assistant"
177
+ and _has_signal_any(extract_text_for_processing(turns[i]))
177
178
  for i in range(thread_start, scan_end + 1)
178
179
  )
179
180
  if not (has_user and has_asst):
@@ -31,7 +31,7 @@ def main() -> None:
31
31
  messages = [
32
32
  {"role": "system", "content": "You are a concise assistant."},
33
33
  {"role": "user", "content": "What is Python? " + ("Explain briefly. " * 200)},
34
- {"role": "assistant", "content": "Python is a programming language. " *150},
34
+ {"role": "assistant", "content": "Python is a programming language. " * 150},
35
35
  {"role": "user", "content": "And numpy? " * 80},
36
36
  ]
37
37
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "contextpress"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Deterministic context compression for LLM chat, RAG, and agent pipelines"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -59,4 +59,15 @@ packages = ["contextpress"]
59
59
  "contextpress/py.typed" = "contextpress/py.typed"
60
60
 
61
61
  [tool.hatch.build.targets.sdist]
62
- include = ["/contextpress", "/tests", "/examples", "/README.md", "/LICENSE", "/NOTICE", "/CHANGELOG.md", "/CITATION.cff"]
62
+ include = ["/contextpress", "/tests", "/examples", "/README.md", "/LICENSE", "/NOTICE", "/CHANGELOG.md", "/CITATION.cff", "/CONTRIBUTING.md"]
63
+
64
+ [tool.black]
65
+ line-length = 100
66
+ target-version = ["py310", "py311", "py312", "py313"]
67
+
68
+ [tool.ruff]
69
+ line-length = 100
70
+ target-version = "py310"
71
+
72
+ [tool.ruff.lint]
73
+ select = ["E", "F", "I", "W", "UP", "B"]
@@ -48,7 +48,7 @@ def test_just_in_time_preserved():
48
48
 
49
49
 
50
50
  def test_empty_conversation():
51
- out = _run()( []).turns
51
+ out = _run()([]).turns
52
52
  assert out == []
53
53
 
54
54
 
@@ -0,0 +1,65 @@
1
+ from unittest.mock import MagicMock
2
+
3
+ import pytest
4
+
5
+ from contextpress.llm._helpers import parse_keep_indices
6
+ from contextpress.llm.adapters import AnthropicBackend, OllamaBackend, OpenAIBackend
7
+
8
+
9
+ def test_parse_keep_indices_comma_separated():
10
+ assert parse_keep_indices("0, 2, 4", 6) == [0, 2, 4]
11
+
12
+
13
+ def test_parse_keep_indices_json_array():
14
+ assert parse_keep_indices("[1, 3]", 4) == [1, 3]
15
+
16
+
17
+ def test_parse_keep_indices_empty_falls_back_to_all():
18
+ assert parse_keep_indices("", 3) == [0, 1, 2]
19
+
20
+
21
+ def test_parse_keep_indices_invalid_falls_back_to_all():
22
+ assert parse_keep_indices("keep everything please", 2) == [0, 1]
23
+
24
+
25
+ def test_openai_deduplicate_parses_response():
26
+ client = MagicMock()
27
+ client.chat.completions.create.return_value = MagicMock(
28
+ choices=[MagicMock(message=MagicMock(content="0, 2"))]
29
+ )
30
+ b = OpenAIBackend(client=client)
31
+ assert b.deduplicate(["a", "b", "c", "d"]) == [0, 2]
32
+ client.chat.completions.create.assert_called_once()
33
+
34
+
35
+ def test_openai_deduplicate_single_turn_skips_llm():
36
+ client = MagicMock()
37
+ b = OpenAIBackend(client=client)
38
+ assert b.deduplicate(["only one"]) == [0]
39
+ client.chat.completions.create.assert_not_called()
40
+
41
+
42
+ def test_openai_deduplicate_error_keeps_all():
43
+ client = MagicMock()
44
+ client.chat.completions.create.side_effect = RuntimeError("network")
45
+ b = OpenAIBackend(client=client)
46
+ with pytest.warns(UserWarning):
47
+ assert b.deduplicate(["a", "b", "c"]) == [0, 1, 2]
48
+
49
+
50
+ def test_anthropic_deduplicate_parses_response():
51
+ client = MagicMock()
52
+ block = MagicMock()
53
+ block.text = "0,1"
54
+ msg = MagicMock()
55
+ msg.content = [block]
56
+ client.messages.create.return_value = msg
57
+ b = AnthropicBackend(client=client)
58
+ assert b.deduplicate(["x", "y", "z"]) == [0, 1]
59
+
60
+
61
+ def test_ollama_deduplicate_parses_response():
62
+ client = MagicMock()
63
+ client.chat.return_value = {"message": {"content": "0, 2"}}
64
+ b = OllamaBackend(client=client, model="llama3.2")
65
+ assert b.deduplicate(["a", "b", "c"]) == [0, 2]
@@ -1,4 +1,4 @@
1
- from datetime import UTC, datetime
1
+ from datetime import datetime, timezone
2
2
 
3
3
  from contextpress.models import ContentBlock, Conversation, Turn
4
4
 
@@ -22,5 +22,5 @@ def test_non_system_turns():
22
22
 
23
23
 
24
24
  def test_timestamp_optional():
25
- t = Turn(role="user", content="x", timestamp=datetime.now(UTC))
25
+ t = Turn(role="user", content="x", timestamp=datetime.now(timezone.utc))
26
26
  assert t.timestamp is not None
@@ -40,7 +40,10 @@ def test_disable_resolution():
40
40
 
41
41
  def test_system_survives():
42
42
  cm = ContextManager(type="chat")
43
- messages = [{"role": "system", "content": "You are helpful."}, {"role": "user", "content": "hi"}]
43
+ messages = [
44
+ {"role": "system", "content": "You are helpful."},
45
+ {"role": "user", "content": "hi"},
46
+ ]
44
47
  r = cm.compress(messages, token_budget=500)
45
48
  assert r[0]["role"] == "system"
46
49
  assert r[0]["content"] == "You are helpful."
@@ -216,11 +219,12 @@ def test_anthropic_backend_summarize_warns_on_error():
216
219
  b.summarize("x", 10)
217
220
 
218
221
 
219
- def test_adapter_deduplicate_defaults():
222
+ def test_adapter_deduplicate_single_turn():
220
223
  client = MagicMock()
221
- assert OpenAIBackend(client=client).deduplicate(["a", "b"]) == [0, 1]
224
+ assert OpenAIBackend(client=client).deduplicate(["a"]) == [0]
222
225
  assert AnthropicBackend(client=client).deduplicate(["a"]) == [0]
223
- assert OllamaBackend(client=client, model="x").deduplicate(["a", "b"]) == [0, 1]
226
+ assert OllamaBackend(client=client, model="x").deduplicate(["a"]) == [0]
227
+ client.chat.completions.create.assert_not_called()
224
228
 
225
229
 
226
230
  def test_ollama_backend_summarize_dict_response():
@@ -0,0 +1,40 @@
1
+ from contextpress import CompressionResult, ContextManager
2
+
3
+
4
+ def test_compress_without_stats_returns_list():
5
+ cm = ContextManager(type="chat")
6
+ messages = [{"role": "user", "content": "hello basically there"}]
7
+ out = cm.compress(messages, token_budget=500)
8
+ assert isinstance(out, list)
9
+
10
+
11
+ def test_compress_with_stats_returns_result():
12
+ cm = ContextManager(type="chat")
13
+ messages = [
14
+ {"role": "system", "content": "You are helpful."},
15
+ {"role": "user", "content": "Old question: " + "blah " * 80},
16
+ {"role": "assistant", "content": "Basically, honestly, " + "yak " * 80},
17
+ {"role": "user", "content": "What is 2+2?"},
18
+ {"role": "assistant", "content": "Four."},
19
+ ]
20
+ result = cm.compress(messages, token_budget=120, return_stats=True)
21
+ assert isinstance(result, CompressionResult)
22
+ assert isinstance(result.messages, list)
23
+ assert result.stats.turns_before == 5
24
+ assert result.stats.turns_after <= result.stats.turns_before
25
+ assert result.stats.tokens_before >= result.stats.tokens_after
26
+ assert result.stats.tokens_saved >= 0
27
+ assert result.stats.compression_level == "medium"
28
+ assert result.stats.context_type == "chat"
29
+ assert result.stats.token_budget == 120
30
+ assert "filler" in result.stats.stages_run
31
+
32
+
33
+ def test_stats_turn_delta_by_stage():
34
+ cm = ContextManager(type="chat", compression="low")
35
+ messages = [
36
+ {"role": "user", "content": "hello basically"},
37
+ {"role": "assistant", "content": "sounds good"},
38
+ ]
39
+ result = cm.compress(messages, token_budget=None, return_stats=True)
40
+ assert isinstance(result.stats.turn_delta_by_stage, dict)
@@ -1,10 +0,0 @@
1
- # Changelog
2
-
3
- All notable changes to `contextpress` are recorded here.
4
- The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
5
- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
-
7
- ## [0.1.0] - 2026-04-19
8
-
9
- - Initial release by Taha Azizi.
10
- - Tier 1 pipeline (filler, repetition, resolution, recency, budget) and optional Tier 2 `LLMBackend` (OpenAI, Anthropic, Ollama adapters).
File without changes
File without changes
File without changes
File without changes