contextpress 0.1.0__tar.gz → 0.3.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 (47) hide show
  1. {contextpress-0.1.0 → contextpress-0.3.0}/.gitignore +3 -0
  2. contextpress-0.3.0/CHANGELOG.md +24 -0
  3. contextpress-0.3.0/CONTRIBUTING.md +61 -0
  4. {contextpress-0.1.0 → contextpress-0.3.0}/PKG-INFO +33 -2
  5. {contextpress-0.1.0 → contextpress-0.3.0}/README.md +32 -1
  6. {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/__init__.py +10 -3
  7. {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/compression.py +20 -4
  8. {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/core.py +52 -8
  9. contextpress-0.3.0/contextpress/llm/_helpers.py +57 -0
  10. {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/llm/adapters.py +53 -4
  11. {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/models.py +3 -1
  12. {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/normalizer.py +25 -8
  13. {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/pipeline.py +120 -23
  14. contextpress-0.3.0/contextpress/registry.py +85 -0
  15. contextpress-0.3.0/contextpress/stats.py +67 -0
  16. {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/strategies/filler.py +1 -1
  17. {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/strategies/recency.py +4 -2
  18. {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/strategies/repetition.py +3 -1
  19. {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/strategies/resolution.py +3 -2
  20. contextpress-0.3.0/examples/estimate_and_stats.py +21 -0
  21. {contextpress-0.1.0 → contextpress-0.3.0}/examples/llm_tier_openai.py +1 -1
  22. {contextpress-0.1.0 → contextpress-0.3.0}/pyproject.toml +13 -2
  23. {contextpress-0.1.0 → contextpress-0.3.0}/tests/test_filler.py +1 -1
  24. contextpress-0.3.0/tests/test_llm_helpers.py +65 -0
  25. {contextpress-0.1.0 → contextpress-0.3.0}/tests/test_models.py +2 -2
  26. {contextpress-0.1.0 → contextpress-0.3.0}/tests/test_pipeline.py +8 -4
  27. contextpress-0.3.0/tests/test_stats.py +40 -0
  28. contextpress-0.3.0/tests/test_v03.py +128 -0
  29. contextpress-0.1.0/CHANGELOG.md +0 -10
  30. {contextpress-0.1.0 → contextpress-0.3.0}/CITATION.cff +0 -0
  31. {contextpress-0.1.0 → contextpress-0.3.0}/LICENSE +0 -0
  32. {contextpress-0.1.0 → contextpress-0.3.0}/NOTICE +0 -0
  33. {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/_bootstrap.py +0 -0
  34. {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/llm/__init__.py +0 -0
  35. {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/llm/base.py +0 -0
  36. {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/profiles.py +0 -0
  37. {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/py.typed +0 -0
  38. {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/strategies/__init__.py +0 -0
  39. {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/strategies/base.py +0 -0
  40. {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/strategies/budget.py +0 -0
  41. {contextpress-0.1.0 → contextpress-0.3.0}/examples/llm_tier_ollama.py +0 -0
  42. {contextpress-0.1.0 → contextpress-0.3.0}/tests/__init__.py +0 -0
  43. {contextpress-0.1.0 → contextpress-0.3.0}/tests/test_budget.py +0 -0
  44. {contextpress-0.1.0 → contextpress-0.3.0}/tests/test_normalizer.py +0 -0
  45. {contextpress-0.1.0 → contextpress-0.3.0}/tests/test_recency.py +0 -0
  46. {contextpress-0.1.0 → contextpress-0.3.0}/tests/test_repetition.py +0 -0
  47. {contextpress-0.1.0 → contextpress-0.3.0}/tests/test_resolution.py +0 -0
@@ -29,3 +29,6 @@ htmlcov/
29
29
  *.swp
30
30
  .DS_Store
31
31
  Thumbs.db
32
+
33
+ # Secrets (never commit)
34
+ .env
@@ -0,0 +1,24 @@
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.3.0] - 2026-07-14
8
+
9
+ - **`estimate_tokens(messages)`** — count tokens before compressing (same tiktoken encoding as budget).
10
+ - **Stage registry** — `ContextManager.register_stage()` for custom `BaseStrategy` stages in `stages=[...]`.
11
+ - **Tier 2 `llm_mode`** — `replace_all` (default), `dedupe_only`, or `summarize_only`.
12
+ - Example: `examples/estimate_and_stats.py`.
13
+
14
+ ## [0.2.0] - 2026-05-24
15
+
16
+ - **`return_stats=True`** on `ContextManager.compress()` returns a `CompressionResult` with token/turn counts, stages run, and per-stage turn deltas.
17
+ - **Tier 2 deduplication** implemented in OpenAI, Anthropic, and Ollama adapters (LLM selects indices to keep).
18
+ - **GitHub Actions CI** runs `pytest` on Python 3.10–3.13.
19
+ - Added **`CONTRIBUTING.md`** with contribution and review expectations.
20
+
21
+ ## [0.1.0] - 2026-04-19
22
+
23
+ - Initial release by Taha Azizi.
24
+ - 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.3.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,35 @@ 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
+
293
+ Check token count **before** compressing:
294
+
295
+ ```python
296
+ before = cm.estimate_tokens(messages)
297
+ ```
298
+
299
+ ### Custom stages (0.3+)
300
+
301
+ Register a `BaseStrategy` and include it in `stages=`:
302
+
303
+ ```python
304
+ from contextpress.strategies.base import BaseStrategy
305
+
306
+ class MyStage(BaseStrategy):
307
+ def process(self, conversation):
308
+ ...
309
+
310
+ cm.register_stage("my_stage", MyStage)
311
+ out = cm.compress(messages, stages=["filler", "my_stage", "budget"], token_budget=500)
312
+ ```
313
+
285
314
  ### Minimal examples
286
315
 
287
316
  ```python
@@ -397,7 +426,9 @@ After **Tier 1** finishes, you can attach an **`LLMBackend`** for semantic compr
397
426
  2. If the combined transcript is long enough (default **1500** characters; set **`llm_min_input_chars=0`** to always run), calls **`summarize(transcript, max_tokens)`**.
398
427
  3. **System turns are unchanged** in order and content. **All other turns are replaced** by a **single assistant** message whose content is the LLM summary (metadata includes `source: contextpress_llm_tier`). If the LLM call fails, the Tier 1 conversation is returned and a **warning** is emitted.
399
428
 
400
- Optional constructor knobs: **`llm_min_input_chars`**, **`llm_max_summary_tokens`**.
429
+ Optional constructor knobs: **`llm_min_input_chars`**, **`llm_max_summary_tokens`**, **`llm_mode`**.
430
+
431
+ **`llm_mode`** (0.3+): `replace_all` (default — dedupe then one summary turn), `dedupe_only` (dedupe, keep turns), `summarize_only` (append summary, keep turns).
401
432
 
402
433
  **Install SDKs** (not bundled): `pip install openai`, `anthropic`, and/or **`ollama`** (for local Ollama), or `pip install "contextpress[llm]"` from this repo’s `pyproject.toml` to pull all optional LLM clients.
403
434
 
@@ -42,6 +42,35 @@ 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
+
53
+ Check token count **before** compressing:
54
+
55
+ ```python
56
+ before = cm.estimate_tokens(messages)
57
+ ```
58
+
59
+ ### Custom stages (0.3+)
60
+
61
+ Register a `BaseStrategy` and include it in `stages=`:
62
+
63
+ ```python
64
+ from contextpress.strategies.base import BaseStrategy
65
+
66
+ class MyStage(BaseStrategy):
67
+ def process(self, conversation):
68
+ ...
69
+
70
+ cm.register_stage("my_stage", MyStage)
71
+ out = cm.compress(messages, stages=["filler", "my_stage", "budget"], token_budget=500)
72
+ ```
73
+
45
74
  ### Minimal examples
46
75
 
47
76
  ```python
@@ -157,7 +186,9 @@ After **Tier 1** finishes, you can attach an **`LLMBackend`** for semantic compr
157
186
  2. If the combined transcript is long enough (default **1500** characters; set **`llm_min_input_chars=0`** to always run), calls **`summarize(transcript, max_tokens)`**.
158
187
  3. **System turns are unchanged** in order and content. **All other turns are replaced** by a **single assistant** message whose content is the LLM summary (metadata includes `source: contextpress_llm_tier`). If the LLM call fails, the Tier 1 conversation is returned and a **warning** is emitted.
159
188
 
160
- Optional constructor knobs: **`llm_min_input_chars`**, **`llm_max_summary_tokens`**.
189
+ Optional constructor knobs: **`llm_min_input_chars`**, **`llm_max_summary_tokens`**, **`llm_mode`**.
190
+
191
+ **`llm_mode`** (0.3+): `replace_all` (default — dedupe then one summary turn), `dedupe_only` (dedupe, keep turns), `summarize_only` (append summary, keep turns).
161
192
 
162
193
  **Install SDKs** (not bundled): `pip install openai`, `anthropic`, and/or **`ollama`** (for local Ollama), or `pip install "contextpress[llm]"` from this repo’s `pyproject.toml` to pull all optional LLM clients.
163
194
 
@@ -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.3.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
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from contextpress.profiles import Profile
5
+ from contextpress.profiles import Profile, StageConfig
6
6
 
7
7
  STAGE_ORDER: tuple[str, ...] = (
8
8
  "filler",
@@ -14,6 +14,14 @@ STAGE_ORDER: tuple[str, ...] = (
14
14
 
15
15
  VALID_STAGES = frozenset(STAGE_ORDER)
16
16
 
17
+
18
+ def known_stages() -> frozenset[str]:
19
+ """Built-in plus registered custom stage names."""
20
+ from contextpress.registry import registered_stage_names
21
+
22
+ return VALID_STAGES | registered_stage_names()
23
+
24
+
17
25
  _NON_BUDGET_ORDER: tuple[str, ...] = tuple(s for s in STAGE_ORDER if s != "budget")
18
26
 
19
27
  # NLP stages only; budget is toggled from token_budget (see apply_stage_selection)
@@ -38,6 +46,7 @@ __all__ = [
38
46
  "STAGE_ORDER",
39
47
  "VALID_STAGES",
40
48
  "apply_stage_selection",
49
+ "known_stages",
41
50
  "normalize_compression_level",
42
51
  ]
43
52
 
@@ -60,6 +69,7 @@ def apply_stage_selection(
60
69
  stages: list[str] | None,
61
70
  disable: list[str] | None,
62
71
  token_budget: int | None,
72
+ custom_stages: dict[str, StageConfig] | None = None,
63
73
  ) -> None:
64
74
  """
65
75
  Mutates ``profile`` in place: sets each stage's ``enabled`` from explicit
@@ -70,12 +80,15 @@ def apply_stage_selection(
70
80
  if stages is not None:
71
81
  if not stages:
72
82
  raise ValueError("stages= must list at least one stage name when provided")
73
- unknown = [s for s in stages if s not in VALID_STAGES]
83
+ unknown = [s for s in stages if s not in known_stages()]
74
84
  if unknown:
75
- raise ValueError(f"unknown stage name(s): {unknown}; valid: {sorted(VALID_STAGES)}")
85
+ raise ValueError(f"unknown stage name(s): {unknown}; valid: {sorted(known_stages())}")
76
86
  want = frozenset(stages)
77
87
  for name in _NON_BUDGET_ORDER:
78
88
  getattr(profile, name).enabled = name in want
89
+ if custom_stages:
90
+ for name, cfg in custom_stages.items():
91
+ cfg.enabled = name in want
79
92
  else:
80
93
  level = normalize_compression_level(compression)
81
94
  preset = _COMPRESSION_PRESETS[level]
@@ -85,6 +98,9 @@ def apply_stage_selection(
85
98
 
86
99
  if disable:
87
100
  for name in disable:
101
+ if custom_stages and name in custom_stages:
102
+ custom_stages[name].enabled = False
103
+ continue
88
104
  if not hasattr(profile, name):
89
105
  continue
90
106
  getattr(profile, name).enabled = False
@@ -98,4 +114,4 @@ def apply_stage_selection(
98
114
  if disable and "budget" in disable:
99
115
  profile.budget.enabled = False
100
116
  else:
101
- profile.budget.enabled = getattr(base_profile, "budget").enabled
117
+ profile.budget.enabled = base_profile.budget.enabled
@@ -6,11 +6,16 @@ from typing import TYPE_CHECKING, Any
6
6
 
7
7
  from contextpress.compression import apply_stage_selection, normalize_compression_level
8
8
  from contextpress.normalizer import denormalize_output, normalize_messages
9
- from contextpress.pipeline import Pipeline
9
+ from contextpress.pipeline import VALID_LLM_MODES, Pipeline
10
10
  from contextpress.profiles import PROFILES, Profile, StageConfig
11
+ from contextpress.registry import register_stage as _register_stage
12
+ from contextpress.stats import CompressionResult, CompressionStats, count_conversation_tokens
11
13
 
12
14
  if TYPE_CHECKING:
15
+ from collections.abc import Callable
16
+
13
17
  from contextpress.llm.base import LLMBackend
18
+ from contextpress.strategies.base import BaseStrategy
14
19
 
15
20
 
16
21
  def _validate_token_budget(token_budget: int | None) -> None:
@@ -38,9 +43,14 @@ class ContextManager:
38
43
  compression: str = "medium",
39
44
  llm_min_input_chars: int = 1500,
40
45
  llm_max_summary_tokens: int = 2048,
46
+ llm_mode: str = "replace_all",
41
47
  ):
42
48
  if type not in PROFILES:
43
49
  raise ValueError(f"unknown context type {type!r}")
50
+ if llm_mode not in VALID_LLM_MODES:
51
+ raise ValueError(
52
+ f"unknown llm_mode {llm_mode!r}; use one of: {sorted(VALID_LLM_MODES)}"
53
+ )
44
54
  self._type = type
45
55
  self._profile: Profile = copy.deepcopy(PROFILES[type])
46
56
  self._compression: str = normalize_compression_level(compression)
@@ -48,6 +58,25 @@ class ContextManager:
48
58
  self.llm_backend = llm_backend
49
59
  self.llm_min_input_chars = int(llm_min_input_chars)
50
60
  self.llm_max_summary_tokens = int(llm_max_summary_tokens)
61
+ self.llm_mode = llm_mode
62
+ self._custom_stages: dict[str, StageConfig] = {}
63
+
64
+ def estimate_tokens(self, messages: Any, *, model: str | None = None) -> int:
65
+ """Count tokens for ``messages`` using the same encoding as the budget stage."""
66
+ conv, _ = normalize_messages(messages, context_type=self._type)
67
+ return count_conversation_tokens(conv, model if model is not None else self.model)
68
+
69
+ def register_stage(
70
+ self,
71
+ name: str,
72
+ factory: Callable[..., BaseStrategy],
73
+ *,
74
+ before: str = "budget",
75
+ aggressiveness: float = 0.5,
76
+ ) -> None:
77
+ """Register a custom pipeline stage (see ``contextpress.registry``)."""
78
+ _register_stage(name, factory, before=before)
79
+ self._custom_stages[name] = StageConfig(enabled=False, aggressiveness=aggressiveness)
51
80
 
52
81
  def compress(
53
82
  self,
@@ -57,23 +86,29 @@ class ContextManager:
57
86
  compression: str | None = None,
58
87
  stages: list[str] | None = None,
59
88
  disable: list[str] | None = None,
60
- ) -> Any:
89
+ return_stats: bool = False,
90
+ ) -> Any | CompressionResult:
61
91
  """Run the pipeline; return value matches input shape (dict list, tuples, strings, etc.).
62
92
 
63
93
  ``token_budget`` must be a positive int or None. Unknown keys in ``disable`` are ignored.
94
+ With ``return_stats=True``, returns a ``CompressionResult`` with ``messages`` and ``stats``.
64
95
  """
65
96
  _validate_token_budget(token_budget)
97
+ level = compression if compression is not None else self._compression
66
98
  profile = copy.deepcopy(self._profile)
99
+ custom_stages = copy.deepcopy(self._custom_stages)
67
100
  apply_stage_selection(
68
101
  profile,
69
102
  base_profile=self._profile,
70
- compression=compression if compression is not None else self._compression,
103
+ compression=level,
71
104
  stages=stages,
72
105
  disable=disable,
73
106
  token_budget=token_budget,
107
+ custom_stages=custom_stages,
74
108
  )
75
109
 
76
110
  conv, ctx = normalize_messages(messages, context_type=self._type)
111
+ stats = CompressionStats(compression_level=level) if return_stats else None
77
112
  pipeline = Pipeline(
78
113
  profile,
79
114
  token_budget=token_budget,
@@ -81,19 +116,28 @@ class ContextManager:
81
116
  llm_backend=self.llm_backend,
82
117
  llm_min_input_chars=self.llm_min_input_chars,
83
118
  llm_max_summary_tokens=self.llm_max_summary_tokens,
119
+ llm_mode=self.llm_mode,
120
+ custom_stages=custom_stages,
84
121
  )
85
- out = pipeline.run(conv)
86
- return denormalize_output(out, ctx)
122
+ out = pipeline.run(conv, stats=stats)
123
+ messages_out = denormalize_output(out, ctx)
124
+ if return_stats:
125
+ assert stats is not None
126
+ return CompressionResult(messages=messages_out, stats=stats)
127
+ return messages_out
87
128
 
88
129
  def set_compression(self, compression: str) -> None:
89
130
  """Change the default preset for subsequent ``compress()`` calls (low / medium / high)."""
90
131
  self._compression = normalize_compression_level(compression)
91
132
 
92
133
  def configure(self, stage: str, **kwargs: Any) -> None:
93
- """Patch ``StageConfig`` fields on the live profile (e.g. ``aggressiveness``, ``enabled``)."""
94
- if not hasattr(self._profile, stage):
134
+ """Patch ``StageConfig`` fields on the live profile (e.g. aggressiveness, enabled)."""
135
+ if stage in self._custom_stages:
136
+ sc = self._custom_stages[stage]
137
+ elif hasattr(self._profile, stage):
138
+ sc = getattr(self._profile, stage)
139
+ else:
95
140
  raise ValueError(f"unknown stage {stage!r}")
96
- sc: StageConfig = getattr(self._profile, stage)
97
141
  for k, v in kwargs.items():
98
142
  if hasattr(sc, k):
99
143
  setattr(sc, k, v)
@@ -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,