cyrrus 0.1.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.
cyrrus-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,236 @@
1
+ Metadata-Version: 2.4
2
+ Name: cyrrus
3
+ Version: 0.1.0
4
+ Summary: Context routing for LLM calls. Decides what goes into the prompt so you don't have to send everything every time.
5
+ License: AGPL-3.0
6
+ Project-URL: Homepage, https://github.com/kikusuka/cyrrus
7
+ Project-URL: Issues, https://github.com/kikusuka/cyrrus/issues
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ Provides-Extra: embeddings
11
+ Requires-Dist: fastembed; extra == "embeddings"
12
+ Requires-Dist: numpy; extra == "embeddings"
13
+
14
+ # cyrrus
15
+
16
+ **Context routing for LLM calls.**
17
+
18
+ Instead of sending a full static system prompt every time, cyrrus decides what context is relevant *right now* and sends only that.
19
+
20
+ ```python
21
+ from cyrrus import Projector
22
+ from cyrrus.providers import ollama
23
+
24
+ bot = Projector.minimal(llm_call=ollama("llama3.2"))
25
+ reply = bot.ask("hello")
26
+ ```
27
+
28
+ That's it. No config required to start.
29
+
30
+ ---
31
+
32
+ ## What it does
33
+
34
+ Every time a user sends a message, cyrrus:
35
+
36
+ 1. **Routes** — matches the message against your slides to find what's relevant
37
+ 2. **Remembers** — pulls stored facts about this user that relate to this message
38
+ 3. **Packs** — fits the relevant context into your token budget, highest priority first
39
+ 4. **Sends** — builds a proper messages array and calls your LLM
40
+ 5. **Learns** — extracts new facts from the conversation and stores them for later
41
+
42
+ The LLM receives clean, relevant context. Not a wall of everything you've ever defined.
43
+
44
+ ---
45
+
46
+ ## Numbers
47
+
48
+ - **51.6% token reduction** on average across mixed conversations (real tiktoken counts)
49
+ - **0.018 MB** core install — zero required dependencies
50
+ - **121.9 MB / 241.7 MB RAM** for the optional embeddings block
51
+ - **500 concurrent users** with zero errors
52
+ - Surviving every adversarial test thrown at it
53
+
54
+ ---
55
+
56
+ ## Install
57
+
58
+ ```bash
59
+ pip install cyrrus # core, zero dependencies
60
+ pip install cyrrus[embeddings] # + semantic routing and memory
61
+ ```
62
+
63
+ The import is always:
64
+ ```python
65
+ from cyrrus import Projector
66
+ ```
67
+
68
+ ---
69
+
70
+ ## Three ways to use it
71
+
72
+ ### Zero config
73
+
74
+ ```python
75
+ from cyrrus import Projector
76
+ from cyrrus.providers import ollama
77
+
78
+ bot = Projector.minimal(llm_call=ollama("llama3.2"))
79
+ reply = bot.ask("write me a python function")
80
+ ```
81
+
82
+ ### With context routing
83
+
84
+ ```python
85
+ config = {
86
+ "core_lamp": {
87
+ "content": "You are a helpful assistant.",
88
+ },
89
+ "code_lens": {
90
+ "content": "Output only clean code blocks, no filler.",
91
+ "triggers": ["code", "script", "python", "function", "bug"],
92
+ },
93
+ "casual_lens": {
94
+ "content": "Keep it short and natural.",
95
+ "triggers": ["hey", "hi", "hello"],
96
+ },
97
+ }
98
+
99
+ bot = Projector(config, llm_call=ollama("llama3.2"))
100
+ reply = await bot.process("write a sorting function", session_id=str(user_id))
101
+ ```
102
+
103
+ `tokens` and `priority` are optional — cyrrus fills them in automatically.
104
+
105
+ ### Individual components
106
+
107
+ ```python
108
+ from cyrrus import MemoryVault, IntentRouter, SlideTray
109
+
110
+ # Use any component standalone
111
+ memory = MemoryVault()
112
+ facts = await memory.retrieve("user_123", "what is my name", limit=3)
113
+ ```
114
+
115
+ ---
116
+
117
+ ## Providers
118
+
119
+ Ready-made wrappers for common APIs. Pass them directly as `llm_call`:
120
+
121
+ ```python
122
+ from cyrrus.providers import ollama, openai, anthropic, groq
123
+
124
+ # Local Ollama — free, no API key
125
+ bot = Projector(config, llm_call=ollama("llama3.2"))
126
+
127
+ # OpenAI
128
+ bot = Projector(config, llm_call=openai("gpt-4o-mini", api_key="sk-..."))
129
+
130
+ # Anthropic
131
+ bot = Projector(config, llm_call=anthropic("claude-haiku-4-5", api_key="sk-..."))
132
+
133
+ # Groq — fast, generous free tier
134
+ bot = Projector(config, llm_call=groq("llama-3.1-8b-instant", api_key="..."))
135
+ ```
136
+
137
+ Or bring your own — any async function that takes a messages list and returns a string:
138
+
139
+ ```python
140
+ async def my_llm(messages: list) -> str:
141
+ response = await client.chat.completions.create(
142
+ model="my-model",
143
+ messages=messages,
144
+ )
145
+ return response.choices[0].message.content
146
+
147
+ bot = Projector(config, llm_call=my_llm)
148
+ ```
149
+
150
+ ---
151
+
152
+ ## Memory
153
+
154
+ cyrrus remembers things users tell it. No setup needed — the default extractor runs automatically:
155
+
156
+ ```
157
+ User: "my name is Muratha" → stored: user_name = Muratha
158
+ User: "I'm building cyrrus" → stored: user_project = cyrrus
159
+ User: "I prefer Ollama" → stored: user_preference = Ollama
160
+ ```
161
+
162
+ On the next message, relevant facts are retrieved and injected into the system prompt automatically.
163
+
164
+ **With `cyrrus[embeddings]`:** retrieval uses semantic similarity, so "what am I working on" finds `user_project: cyrrus` even without shared keywords.
165
+
166
+ **Without:** keyword overlap matching. Fast, zero dependencies, catches most cases.
167
+
168
+ Memory is per `session_id`. Facts never leak between users.
169
+
170
+ ---
171
+
172
+ ## Conversation history
173
+
174
+ cyrrus maintains a sliding window of conversation history per session. The LLM always sees recent context, so follow-up questions work:
175
+
176
+ ```
177
+ User: "what's the capital of France?"
178
+ Bot: "Paris."
179
+ User: "what's the population there?" ← LLM knows "there" means Paris
180
+ Bot: "Around 2.1 million in the city proper."
181
+ ```
182
+
183
+ ---
184
+
185
+ ## What cyrrus is not
186
+
187
+ - **Not an agent framework.** It controls input, never output.
188
+ - **Not a vector database.** Memory is SQLite with semantic retrieval optional.
189
+ - **Not a replacement for LangChain.** It's a layer, not a framework.
190
+ - **Not magic.** Keyword routing misses natural phrasing. Install `cyrrus[embeddings]` if you need paraphrase matching.
191
+
192
+ ---
193
+
194
+ ## session_id
195
+
196
+ This is important. Every user needs a unique `session_id`:
197
+
198
+ ```python
199
+ # Discord bot
200
+ reply = await bot.process(message.content, session_id=str(message.author.id))
201
+
202
+ # Web app
203
+ reply = await bot.process(user_input, session_id=request.session["user_id"])
204
+
205
+ # Single-user script — use ask() and don't worry about it
206
+ reply = bot.ask("hello")
207
+ ```
208
+
209
+ Without a unique session_id, users share memory. That's a bug, not a feature.
210
+
211
+ ---
212
+
213
+ ## Tracing
214
+
215
+ Every call stores a trace:
216
+
217
+ ```python
218
+ await bot.process("write a script", session_id="u1")
219
+
220
+ trace = bot.last_trace
221
+ print(trace["routed_slide_ids"]) # which slides matched
222
+ print(trace["memory_slide_ids"]) # which facts were retrieved
223
+ print(trace["dropped_slide_ids"]) # what didn't fit the budget
224
+ print(trace["messages"]) # exact messages sent to LLM
225
+ print(trace["stats"]["history_turns"]) # how many turns of history
226
+ ```
227
+
228
+ ---
229
+
230
+ ## License
231
+
232
+ AGPLv3. If you run cyrrus as part of a network service, you must make your modifications available. See LICENSE.
233
+
234
+ ---
235
+
236
+ cyrrus — formerly known as slid3s
cyrrus-0.1.0/README.md ADDED
@@ -0,0 +1,223 @@
1
+ # cyrrus
2
+
3
+ **Context routing for LLM calls.**
4
+
5
+ Instead of sending a full static system prompt every time, cyrrus decides what context is relevant *right now* and sends only that.
6
+
7
+ ```python
8
+ from cyrrus import Projector
9
+ from cyrrus.providers import ollama
10
+
11
+ bot = Projector.minimal(llm_call=ollama("llama3.2"))
12
+ reply = bot.ask("hello")
13
+ ```
14
+
15
+ That's it. No config required to start.
16
+
17
+ ---
18
+
19
+ ## What it does
20
+
21
+ Every time a user sends a message, cyrrus:
22
+
23
+ 1. **Routes** — matches the message against your slides to find what's relevant
24
+ 2. **Remembers** — pulls stored facts about this user that relate to this message
25
+ 3. **Packs** — fits the relevant context into your token budget, highest priority first
26
+ 4. **Sends** — builds a proper messages array and calls your LLM
27
+ 5. **Learns** — extracts new facts from the conversation and stores them for later
28
+
29
+ The LLM receives clean, relevant context. Not a wall of everything you've ever defined.
30
+
31
+ ---
32
+
33
+ ## Numbers
34
+
35
+ - **51.6% token reduction** on average across mixed conversations (real tiktoken counts)
36
+ - **0.018 MB** core install — zero required dependencies
37
+ - **121.9 MB / 241.7 MB RAM** for the optional embeddings block
38
+ - **500 concurrent users** with zero errors
39
+ - Surviving every adversarial test thrown at it
40
+
41
+ ---
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ pip install cyrrus # core, zero dependencies
47
+ pip install cyrrus[embeddings] # + semantic routing and memory
48
+ ```
49
+
50
+ The import is always:
51
+ ```python
52
+ from cyrrus import Projector
53
+ ```
54
+
55
+ ---
56
+
57
+ ## Three ways to use it
58
+
59
+ ### Zero config
60
+
61
+ ```python
62
+ from cyrrus import Projector
63
+ from cyrrus.providers import ollama
64
+
65
+ bot = Projector.minimal(llm_call=ollama("llama3.2"))
66
+ reply = bot.ask("write me a python function")
67
+ ```
68
+
69
+ ### With context routing
70
+
71
+ ```python
72
+ config = {
73
+ "core_lamp": {
74
+ "content": "You are a helpful assistant.",
75
+ },
76
+ "code_lens": {
77
+ "content": "Output only clean code blocks, no filler.",
78
+ "triggers": ["code", "script", "python", "function", "bug"],
79
+ },
80
+ "casual_lens": {
81
+ "content": "Keep it short and natural.",
82
+ "triggers": ["hey", "hi", "hello"],
83
+ },
84
+ }
85
+
86
+ bot = Projector(config, llm_call=ollama("llama3.2"))
87
+ reply = await bot.process("write a sorting function", session_id=str(user_id))
88
+ ```
89
+
90
+ `tokens` and `priority` are optional — cyrrus fills them in automatically.
91
+
92
+ ### Individual components
93
+
94
+ ```python
95
+ from cyrrus import MemoryVault, IntentRouter, SlideTray
96
+
97
+ # Use any component standalone
98
+ memory = MemoryVault()
99
+ facts = await memory.retrieve("user_123", "what is my name", limit=3)
100
+ ```
101
+
102
+ ---
103
+
104
+ ## Providers
105
+
106
+ Ready-made wrappers for common APIs. Pass them directly as `llm_call`:
107
+
108
+ ```python
109
+ from cyrrus.providers import ollama, openai, anthropic, groq
110
+
111
+ # Local Ollama — free, no API key
112
+ bot = Projector(config, llm_call=ollama("llama3.2"))
113
+
114
+ # OpenAI
115
+ bot = Projector(config, llm_call=openai("gpt-4o-mini", api_key="sk-..."))
116
+
117
+ # Anthropic
118
+ bot = Projector(config, llm_call=anthropic("claude-haiku-4-5", api_key="sk-..."))
119
+
120
+ # Groq — fast, generous free tier
121
+ bot = Projector(config, llm_call=groq("llama-3.1-8b-instant", api_key="..."))
122
+ ```
123
+
124
+ Or bring your own — any async function that takes a messages list and returns a string:
125
+
126
+ ```python
127
+ async def my_llm(messages: list) -> str:
128
+ response = await client.chat.completions.create(
129
+ model="my-model",
130
+ messages=messages,
131
+ )
132
+ return response.choices[0].message.content
133
+
134
+ bot = Projector(config, llm_call=my_llm)
135
+ ```
136
+
137
+ ---
138
+
139
+ ## Memory
140
+
141
+ cyrrus remembers things users tell it. No setup needed — the default extractor runs automatically:
142
+
143
+ ```
144
+ User: "my name is Muratha" → stored: user_name = Muratha
145
+ User: "I'm building cyrrus" → stored: user_project = cyrrus
146
+ User: "I prefer Ollama" → stored: user_preference = Ollama
147
+ ```
148
+
149
+ On the next message, relevant facts are retrieved and injected into the system prompt automatically.
150
+
151
+ **With `cyrrus[embeddings]`:** retrieval uses semantic similarity, so "what am I working on" finds `user_project: cyrrus` even without shared keywords.
152
+
153
+ **Without:** keyword overlap matching. Fast, zero dependencies, catches most cases.
154
+
155
+ Memory is per `session_id`. Facts never leak between users.
156
+
157
+ ---
158
+
159
+ ## Conversation history
160
+
161
+ cyrrus maintains a sliding window of conversation history per session. The LLM always sees recent context, so follow-up questions work:
162
+
163
+ ```
164
+ User: "what's the capital of France?"
165
+ Bot: "Paris."
166
+ User: "what's the population there?" ← LLM knows "there" means Paris
167
+ Bot: "Around 2.1 million in the city proper."
168
+ ```
169
+
170
+ ---
171
+
172
+ ## What cyrrus is not
173
+
174
+ - **Not an agent framework.** It controls input, never output.
175
+ - **Not a vector database.** Memory is SQLite with semantic retrieval optional.
176
+ - **Not a replacement for LangChain.** It's a layer, not a framework.
177
+ - **Not magic.** Keyword routing misses natural phrasing. Install `cyrrus[embeddings]` if you need paraphrase matching.
178
+
179
+ ---
180
+
181
+ ## session_id
182
+
183
+ This is important. Every user needs a unique `session_id`:
184
+
185
+ ```python
186
+ # Discord bot
187
+ reply = await bot.process(message.content, session_id=str(message.author.id))
188
+
189
+ # Web app
190
+ reply = await bot.process(user_input, session_id=request.session["user_id"])
191
+
192
+ # Single-user script — use ask() and don't worry about it
193
+ reply = bot.ask("hello")
194
+ ```
195
+
196
+ Without a unique session_id, users share memory. That's a bug, not a feature.
197
+
198
+ ---
199
+
200
+ ## Tracing
201
+
202
+ Every call stores a trace:
203
+
204
+ ```python
205
+ await bot.process("write a script", session_id="u1")
206
+
207
+ trace = bot.last_trace
208
+ print(trace["routed_slide_ids"]) # which slides matched
209
+ print(trace["memory_slide_ids"]) # which facts were retrieved
210
+ print(trace["dropped_slide_ids"]) # what didn't fit the budget
211
+ print(trace["messages"]) # exact messages sent to LLM
212
+ print(trace["stats"]["history_turns"]) # how many turns of history
213
+ ```
214
+
215
+ ---
216
+
217
+ ## License
218
+
219
+ AGPLv3. If you run cyrrus as part of a network service, you must make your modifications available. See LICENSE.
220
+
221
+ ---
222
+
223
+ cyrrus — formerly known as slid3s
@@ -0,0 +1,27 @@
1
+ from .projector import Projector
2
+ from .data import Slide
3
+ from .memory import MemoryVault
4
+ from .router import IntentRouter
5
+ from .tray import SlideTray
6
+ from .knapsack import TokenKnapsack
7
+ from .extractor import extract_facts
8
+ from . import providers
9
+
10
+ __version__ = "0.1.0"
11
+
12
+ def estimate_tokens(text: str) -> int:
13
+ """Rough token count. Useful for setting 'tokens' in config manually."""
14
+ return max(1, len(text.split()))
15
+
16
+ __all__ = [
17
+ "Projector",
18
+ "Slide",
19
+ "MemoryVault",
20
+ "IntentRouter",
21
+ "SlideTray",
22
+ "TokenKnapsack",
23
+ "extract_facts",
24
+ "providers",
25
+ "estimate_tokens",
26
+ "__version__",
27
+ ]
@@ -0,0 +1,91 @@
1
+ """
2
+ Extractive tool-result compression: split a tool result into
3
+ sentences, keep only the ones most relevant to the user's message,
4
+ drop the rest. Unlike summarize_tool_output (which calls the LLM and
5
+ costs an extra API call on your own key), this uses the same local
6
+ embedding model as EmbeddingRouter - zero extra API cost.
7
+
8
+ Testable in two layers, on purpose:
9
+ - The SELECTION algorithm (which sentences get kept, in what order)
10
+ is tested with a fake, injected embedder - no network needed, see
11
+ tests/test_compression.py.
12
+ - The actual semantic quality of what gets selected needs a real
13
+ model - verify in Colab, same as EmbeddingRouter.
14
+ """
15
+ import logging
16
+ import re
17
+
18
+ log = logging.getLogger("cyrrus.compression")
19
+
20
+ DEFAULT_MODEL = "BAAI/bge-small-en-v1.5"
21
+
22
+ _SENTENCE_SPLIT_RE = re.compile(r'(?<=[.!?])\s+')
23
+
24
+
25
+ def split_sentences(text: str) -> list:
26
+ """Simple regex-based sentence splitting. Known limitation: will
27
+ mis-split on abbreviations (e.g. "Dr. Smith") - acceptable for
28
+ tool-result text (search snippets, DB rows), not recommended for
29
+ literary prose."""
30
+ if not text or not text.strip():
31
+ return []
32
+ sentences = _SENTENCE_SPLIT_RE.split(text.strip())
33
+ return [s.strip() for s in sentences if s.strip()]
34
+
35
+
36
+ class ExtractiveCompressor:
37
+ def __init__(self, embedder: object = None, model_name: str = DEFAULT_MODEL):
38
+ """embedder: anything with .embed(list[str]) -> list[vector],
39
+ matching fastembed's TextEmbedding interface. Pass a fake one
40
+ in tests to check the selection logic without network. If
41
+ None, tries to load the real local model; falls back to
42
+ returning text unmodified (compression becomes a no-op, not a
43
+ crash) if that fails."""
44
+ self.embedder = embedder
45
+ self._np = None
46
+ if embedder is None:
47
+ self._try_load_default_embedder(model_name)
48
+ else:
49
+ try:
50
+ import numpy as np
51
+ self._np = np
52
+ except ImportError:
53
+ log.warning("numpy not available - compression will no-op even with a custom embedder.")
54
+
55
+ def _try_load_default_embedder(self, model_name: str):
56
+ try:
57
+ from fastembed import TextEmbedding
58
+ import numpy as np
59
+ self.embedder = TextEmbedding(model_name=model_name)
60
+ self._np = np
61
+ except ImportError:
62
+ log.warning("fastembed not installed - tool-result compression will no-op "
63
+ "(results pass through unmodified). Install with: pip install fastembed")
64
+ except Exception as e:
65
+ log.warning("Embedding model failed to load (%s) - compression will no-op.", e)
66
+
67
+ def compress(self, text: str, query: str, max_sentences: int = 3) -> str:
68
+ if self.embedder is None or self._np is None:
69
+ return text # fail-safe: no-op, never crash the caller over this
70
+
71
+ sentences = split_sentences(text)
72
+ if len(sentences) <= max_sentences:
73
+ return text # nothing to trim
74
+
75
+ try:
76
+ sentence_embeddings = self._np.array(list(self.embedder.embed(sentences)))
77
+ query_embedding = self._np.array(list(self.embedder.embed([query]))[0])
78
+
79
+ norms = self._np.linalg.norm(sentence_embeddings, axis=1) * self._np.linalg.norm(query_embedding)
80
+ norms[norms == 0] = 1e-9
81
+ scores = sentence_embeddings.dot(query_embedding) / norms
82
+
83
+ top_indices = sorted(
84
+ range(len(scores)), key=lambda i: scores[i], reverse=True
85
+ )[:max_sentences]
86
+ top_indices.sort() # restore original reading order, don't shuffle the sentences
87
+
88
+ return " ".join(sentences[i] for i in top_indices)
89
+ except Exception as e:
90
+ log.warning("Compression failed (%s) - returning original text uncompressed.", e)
91
+ return text