contextpress 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.
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from contextpress._bootstrap import bootstrap_nltk
6
+ from contextpress.models import ContentBlock, Conversation, Turn
7
+
8
+ bootstrap_nltk()
9
+
10
+ __all__ = ["ContextManager", "Turn", "Conversation", "ContentBlock"]
11
+ __version__ = "0.1.0"
12
+
13
+
14
+ def __getattr__(name: str) -> Any:
15
+ # Lazy import: ContextManager -> core -> pipeline -> strategies (sumy, etc.)
16
+ if name == "ContextManager":
17
+ from contextpress.core import ContextManager
18
+
19
+ return ContextManager
20
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,34 @@
1
+ """One-time NLTK data download.
2
+
3
+ Runs on first import; writes a flag file so later imports stay silent.
4
+ Offline installs will hit the network here — set the flag manually
5
+ (``touch ~/.contextpress/nltk_ready``) or pre-populate NLTK_DATA to skip.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import warnings
11
+ from pathlib import Path
12
+
13
+ import nltk
14
+
15
+
16
+ def bootstrap_nltk() -> None:
17
+ flag = Path.home() / ".contextpress" / "nltk_ready"
18
+ if flag.exists():
19
+ return
20
+ packages = ["punkt", "stopwords", "averaged_perceptron_tagger"]
21
+ failed: list[str] = []
22
+ for pkg in packages:
23
+ try:
24
+ nltk.download(pkg, quiet=True)
25
+ except Exception:
26
+ failed.append(pkg)
27
+ if failed:
28
+ warnings.warn(
29
+ f"contextpress: could not download NLTK data {failed}; "
30
+ "resolution/recency may degrade. Check network or set NLTK_DATA.",
31
+ stacklevel=2,
32
+ )
33
+ flag.parent.mkdir(parents=True, exist_ok=True)
34
+ flag.touch()
@@ -0,0 +1,101 @@
1
+ """Presets (low/medium/high), explicit ``stages=``, and budget toggling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextpress.profiles import Profile
6
+
7
+ STAGE_ORDER: tuple[str, ...] = (
8
+ "filler",
9
+ "repetition",
10
+ "resolution",
11
+ "recency",
12
+ "budget",
13
+ )
14
+
15
+ VALID_STAGES = frozenset(STAGE_ORDER)
16
+
17
+ _NON_BUDGET_ORDER: tuple[str, ...] = tuple(s for s in STAGE_ORDER if s != "budget")
18
+
19
+ # NLP stages only; budget is toggled from token_budget (see apply_stage_selection)
20
+ _COMPRESSION_PRESETS: dict[str, frozenset[str]] = {
21
+ "low": frozenset({"filler", "repetition"}),
22
+ "medium": frozenset({"filler", "repetition", "recency"}),
23
+ "high": frozenset({"filler", "repetition", "resolution", "recency"}),
24
+ }
25
+
26
+ _COMPRESSION_ALIASES: dict[str, str] = {
27
+ "low": "low",
28
+ "light": "low",
29
+ "medium": "medium",
30
+ "med": "medium",
31
+ "mid": "medium",
32
+ "high": "high",
33
+ "max": "high",
34
+ }
35
+
36
+
37
+ __all__ = [
38
+ "STAGE_ORDER",
39
+ "VALID_STAGES",
40
+ "apply_stage_selection",
41
+ "normalize_compression_level",
42
+ ]
43
+
44
+
45
+ def normalize_compression_level(level: str) -> str:
46
+ key = level.strip().lower()
47
+ if key not in _COMPRESSION_ALIASES:
48
+ raise ValueError(
49
+ f"unknown compression level {level!r}; "
50
+ f"use one of: low, medium, high (aliases: light, med, max)"
51
+ )
52
+ return _COMPRESSION_ALIASES[key]
53
+
54
+
55
+ def apply_stage_selection(
56
+ profile: Profile,
57
+ *,
58
+ base_profile: Profile,
59
+ compression: str,
60
+ stages: list[str] | None,
61
+ disable: list[str] | None,
62
+ token_budget: int | None,
63
+ ) -> None:
64
+ """
65
+ Mutates ``profile`` in place: sets each stage's ``enabled`` from explicit
66
+ ``stages``, or from a compression preset merged with ``base_profile`` for
67
+ non-budget stages, then applies ``disable``. Budget is set last from
68
+ ``token_budget`` / ``stages`` / ``disable``.
69
+ """
70
+ if stages is not None:
71
+ if not stages:
72
+ 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]
74
+ if unknown:
75
+ raise ValueError(f"unknown stage name(s): {unknown}; valid: {sorted(VALID_STAGES)}")
76
+ want = frozenset(stages)
77
+ for name in _NON_BUDGET_ORDER:
78
+ getattr(profile, name).enabled = name in want
79
+ else:
80
+ level = normalize_compression_level(compression)
81
+ preset = _COMPRESSION_PRESETS[level]
82
+ for name in _NON_BUDGET_ORDER:
83
+ base_on = getattr(base_profile, name).enabled
84
+ getattr(profile, name).enabled = (name in preset) and base_on
85
+
86
+ if disable:
87
+ for name in disable:
88
+ if not hasattr(profile, name):
89
+ continue
90
+ getattr(profile, name).enabled = False
91
+
92
+ # Budget: token cap enforcement (pipeline still skips if token_budget is None)
93
+ if token_budget is None:
94
+ profile.budget.enabled = False
95
+ elif stages is not None:
96
+ profile.budget.enabled = "budget" in want
97
+ else:
98
+ if disable and "budget" in disable:
99
+ profile.budget.enabled = False
100
+ else:
101
+ profile.budget.enabled = getattr(base_profile, "budget").enabled
contextpress/core.py ADDED
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import warnings
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from contextpress.compression import apply_stage_selection, normalize_compression_level
8
+ from contextpress.normalizer import denormalize_output, normalize_messages
9
+ from contextpress.pipeline import Pipeline
10
+ from contextpress.profiles import PROFILES, Profile, StageConfig
11
+
12
+ if TYPE_CHECKING:
13
+ from contextpress.llm.base import LLMBackend
14
+
15
+
16
+ def _validate_token_budget(token_budget: int | None) -> None:
17
+ if token_budget is None:
18
+ return
19
+ if isinstance(token_budget, bool) or not isinstance(token_budget, int):
20
+ raise TypeError("token_budget must be a positive int or None (bools are not allowed)")
21
+ if token_budget < 1:
22
+ raise ValueError("token_budget must be >= 1 when set")
23
+
24
+
25
+ class ContextManager:
26
+ """Main API: ``compress()`` runs Tier 1 (and Tier 2 if ``llm_backend`` is set).
27
+
28
+ ``model`` is only for tiktoken when enforcing ``token_budget``. It does not call that model
29
+ unless you pass an ``llm_backend`` that uses it.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ type: str = "chat",
35
+ model: str | None = None,
36
+ llm_backend: LLMBackend | None = None,
37
+ *,
38
+ compression: str = "medium",
39
+ llm_min_input_chars: int = 1500,
40
+ llm_max_summary_tokens: int = 2048,
41
+ ):
42
+ if type not in PROFILES:
43
+ raise ValueError(f"unknown context type {type!r}")
44
+ self._type = type
45
+ self._profile: Profile = copy.deepcopy(PROFILES[type])
46
+ self._compression: str = normalize_compression_level(compression)
47
+ self.model = model
48
+ self.llm_backend = llm_backend
49
+ self.llm_min_input_chars = int(llm_min_input_chars)
50
+ self.llm_max_summary_tokens = int(llm_max_summary_tokens)
51
+
52
+ def compress(
53
+ self,
54
+ messages: Any,
55
+ token_budget: int | None = None,
56
+ *,
57
+ compression: str | None = None,
58
+ stages: list[str] | None = None,
59
+ disable: list[str] | None = None,
60
+ ) -> Any:
61
+ """Run the pipeline; return value matches input shape (dict list, tuples, strings, etc.).
62
+
63
+ ``token_budget`` must be a positive int or None. Unknown keys in ``disable`` are ignored.
64
+ """
65
+ _validate_token_budget(token_budget)
66
+ profile = copy.deepcopy(self._profile)
67
+ apply_stage_selection(
68
+ profile,
69
+ base_profile=self._profile,
70
+ compression=compression if compression is not None else self._compression,
71
+ stages=stages,
72
+ disable=disable,
73
+ token_budget=token_budget,
74
+ )
75
+
76
+ conv, ctx = normalize_messages(messages, context_type=self._type)
77
+ pipeline = Pipeline(
78
+ profile,
79
+ token_budget=token_budget,
80
+ model=self.model,
81
+ llm_backend=self.llm_backend,
82
+ llm_min_input_chars=self.llm_min_input_chars,
83
+ llm_max_summary_tokens=self.llm_max_summary_tokens,
84
+ )
85
+ out = pipeline.run(conv)
86
+ return denormalize_output(out, ctx)
87
+
88
+ def set_compression(self, compression: str) -> None:
89
+ """Change the default preset for subsequent ``compress()`` calls (low / medium / high)."""
90
+ self._compression = normalize_compression_level(compression)
91
+
92
+ 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):
95
+ raise ValueError(f"unknown stage {stage!r}")
96
+ sc: StageConfig = getattr(self._profile, stage)
97
+ for k, v in kwargs.items():
98
+ if hasattr(sc, k):
99
+ setattr(sc, k, v)
100
+ unknown = [k for k in kwargs if not hasattr(sc, k)]
101
+ if unknown:
102
+ warnings.warn(
103
+ f"contextpress: configure({stage!r}) ignored unknown key(s): {unknown}",
104
+ stacklevel=2,
105
+ )
@@ -0,0 +1,4 @@
1
+ from contextpress.llm.adapters import AnthropicBackend, OllamaBackend, OpenAIBackend
2
+ from contextpress.llm.base import LLMBackend
3
+
4
+ __all__ = ["LLMBackend", "OpenAIBackend", "AnthropicBackend", "OllamaBackend"]
@@ -0,0 +1,174 @@
1
+ from __future__ import annotations
2
+
3
+ import warnings
4
+ from typing import Any
5
+
6
+
7
+ from contextpress.llm.base import LLMBackend
8
+
9
+
10
+ def _ollama_response_text(resp: Any) -> str:
11
+ """Normalize ollama chat response (object or dict) to a string."""
12
+ if resp is None:
13
+ return ""
14
+ if isinstance(resp, dict):
15
+ msg = resp.get("message") or {}
16
+ if isinstance(msg, dict):
17
+ return str(msg.get("content") or "").strip()
18
+ return str(getattr(msg, "content", "") or "").strip()
19
+ msg = getattr(resp, "message", None)
20
+ if msg is not None:
21
+ c = getattr(msg, "content", None)
22
+ if c is not None:
23
+ return str(c).strip()
24
+ return ""
25
+
26
+
27
+ class OpenAIBackend(LLMBackend):
28
+ """
29
+ Adapter for OpenAI-compatible APIs.
30
+ Requires: pip install openai
31
+ User must pass their own client instance.
32
+
33
+ Usage:
34
+ from openai import OpenAI
35
+ from contextpress.llm.adapters import OpenAIBackend
36
+
37
+ backend = OpenAIBackend(client=OpenAI(), model="gpt-4o-mini")
38
+ cm = ContextManager(type="chat", llm_backend=backend)
39
+ """
40
+
41
+ def __init__(self, client: Any, model: str = "gpt-4o-mini"):
42
+ self.client = client
43
+ self.model = model
44
+
45
+ def summarize(self, text: str, max_tokens: int) -> str:
46
+ try:
47
+ resp = self.client.chat.completions.create(
48
+ model=self.model,
49
+ messages=[
50
+ {"role": "system", "content": "Summarize the following text concisely."},
51
+ {"role": "user", "content": text},
52
+ ],
53
+ max_tokens=max_tokens,
54
+ )
55
+ choice = resp.choices[0]
56
+ content = choice.message.content
57
+ return content if content is not None else text
58
+ except Exception as exc:
59
+ warnings.warn(f"contextpress OpenAIBackend.summarize failed: {exc}", stacklevel=2)
60
+ raise
61
+
62
+ def deduplicate(self, turns: list[str]) -> list[int]:
63
+ return list(range(len(turns)))
64
+
65
+
66
+ class AnthropicBackend(LLMBackend):
67
+ """
68
+ Adapter for Anthropic Claude APIs.
69
+ Requires: pip install anthropic
70
+ User must pass their own client instance.
71
+
72
+ Usage:
73
+ import anthropic
74
+ from contextpress.llm.adapters import AnthropicBackend
75
+
76
+ backend = AnthropicBackend(client=anthropic.Anthropic(), model="claude-haiku-4-5")
77
+ cm = ContextManager(type="chat", llm_backend=backend)
78
+ """
79
+
80
+ def __init__(self, client: Any, model: str = "claude-haiku-4-5"):
81
+ self.client = client
82
+ self.model = model
83
+
84
+ def summarize(self, text: str, max_tokens: int) -> str:
85
+ try:
86
+ msg = self.client.messages.create(
87
+ model=self.model,
88
+ max_tokens=max_tokens,
89
+ messages=[{"role": "user", "content": f"Summarize concisely:\n\n{text}"}],
90
+ )
91
+ parts = []
92
+ for b in msg.content:
93
+ if hasattr(b, "text"):
94
+ parts.append(b.text)
95
+ return "".join(parts) if parts else text
96
+ except Exception as exc:
97
+ warnings.warn(f"contextpress AnthropicBackend.summarize failed: {exc}", stacklevel=2)
98
+ raise
99
+
100
+ def deduplicate(self, turns: list[str]) -> list[int]:
101
+ return list(range(len(turns)))
102
+
103
+
104
+ class OllamaBackend(LLMBackend):
105
+ """
106
+ Adapter for **Ollama** (local or remote) using the official ``ollama`` Python library.
107
+
108
+ Requires: ``pip install ollama``
109
+
110
+ Ollama must be installed and running (see https://ollama.com). Pull a model first, e.g.::
111
+
112
+ ollama pull llama3.2
113
+
114
+ Usage::
115
+
116
+ from contextpress import ContextManager
117
+ from contextpress.llm.adapters import OllamaBackend
118
+
119
+ backend = OllamaBackend(model="llama3.2")
120
+ cm = ContextManager(type="chat", llm_backend=backend, llm_min_input_chars=500)
121
+
122
+ Remote server::
123
+
124
+ backend = OllamaBackend(model="mistral", host="http://192.168.1.10:11434")
125
+
126
+ Custom client (advanced)::
127
+
128
+ from ollama import Client
129
+ backend = OllamaBackend(client=Client(host="http://localhost:11434"), model="llama3.2")
130
+ """
131
+
132
+ def __init__(
133
+ self,
134
+ model: str = "llama3.2",
135
+ *,
136
+ host: str | None = None,
137
+ client: Any | None = None,
138
+ ):
139
+ self.model = model
140
+ if client is not None:
141
+ self._client = client
142
+ return
143
+ try:
144
+ from ollama import Client as OllamaClient
145
+ except ImportError as exc:
146
+ raise ImportError(
147
+ "OllamaBackend requires the 'ollama' package. Install with: pip install ollama"
148
+ ) from exc
149
+ self._client = OllamaClient(host=host) if host else OllamaClient()
150
+
151
+ def summarize(self, text: str, max_tokens: int) -> str:
152
+ try:
153
+ resp = self._client.chat(
154
+ model=self.model,
155
+ messages=[
156
+ {
157
+ "role": "system",
158
+ "content": (
159
+ "Summarize the following conversation transcript concisely. "
160
+ "Preserve important facts, names, and decisions."
161
+ ),
162
+ },
163
+ {"role": "user", "content": text},
164
+ ],
165
+ options={"num_predict": max(64, int(max_tokens))},
166
+ )
167
+ content = _ollama_response_text(resp)
168
+ return content if content.strip() else text
169
+ except Exception as exc:
170
+ warnings.warn(f"contextpress OllamaBackend.summarize failed: {exc}", stacklevel=2)
171
+ raise
172
+
173
+ def deduplicate(self, turns: list[str]) -> list[int]:
174
+ return list(range(len(turns)))
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+
6
+ class LLMBackend(ABC):
7
+ """
8
+ Interface for optional Tier 2 LLM processing.
9
+ Users implement this to connect their preferred LLM provider.
10
+ contextpress never imports any LLM SDK directly.
11
+ """
12
+
13
+ @abstractmethod
14
+ def summarize(self, text: str, max_tokens: int) -> str:
15
+ """
16
+ Abstractively summarize text to fit within max_tokens.
17
+ Must return a plain string.
18
+ """
19
+ ...
20
+
21
+ @abstractmethod
22
+ def deduplicate(self, turns: list[str]) -> list[int]:
23
+ """
24
+ Given a list of turn texts, return indices of turns to KEEP.
25
+ The backend decides which are semantically redundant.
26
+ """
27
+ ...
contextpress/models.py ADDED
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ from typing import Any
6
+
7
+
8
+ @dataclass
9
+ class ContentBlock:
10
+ """
11
+ A single block of content within a turn.
12
+ Used when content is multimodal (text + image, tool call, etc.)
13
+ """
14
+
15
+ type: str # "text" | "image" | "tool_use" | "tool_result"
16
+ content: str # text content or reference string
17
+ mime_type: str | None = None
18
+ metadata: dict[str, Any] = field(default_factory=dict)
19
+
20
+
21
+ @dataclass
22
+ class Turn:
23
+ """
24
+ A single message in a conversation.
25
+ This is the canonical unit the entire pipeline operates on.
26
+ """
27
+
28
+ role: str # "user" | "assistant" | "system"
29
+ content: str | list[ContentBlock] # string for simple, list for multimodal
30
+ timestamp: datetime | None = None
31
+ metadata: dict[str, Any] = field(default_factory=dict)
32
+ importance: float = 1.0 # set by pipeline stages, 0.0–1.0
33
+ resolved: bool = False # True when flagged by resolution detector
34
+ compressed: bool = False # True if content was modified by pipeline
35
+ original_content: str | list[ContentBlock] | None = None # preserves original before compression
36
+
37
+
38
+ @dataclass
39
+ class Conversation:
40
+ """
41
+ An ordered list of turns with a declared context type.
42
+ This is the primary object passed into the pipeline.
43
+ """
44
+
45
+ turns: list[Turn]
46
+ type: str = "chat" # "chat" | "rag_doc" | "agent"
47
+ metadata: dict[str, Any] = field(default_factory=dict)
48
+
49
+ def text_turns(self) -> list[Turn]:
50
+ """Returns only turns that contain extractable text content."""
51
+ return [t for t in self.turns if self._has_text(t)]
52
+
53
+ def non_system_turns(self) -> list[Turn]:
54
+ """Returns all turns except system turns."""
55
+ return [t for t in self.turns if t.role != "system"]
56
+
57
+ @staticmethod
58
+ def _has_text(turn: Turn) -> bool:
59
+ if isinstance(turn.content, str):
60
+ return bool(turn.content.strip())
61
+ return any(b.type == "text" for b in turn.content)