contexara 0.2.1__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.
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.4
2
+ Name: contexara
3
+ Version: 0.2.1
4
+ Summary: CLI-first memory engine for LLM applications.
5
+ Author: Contexara Contributors
6
+ Project-URL: Homepage, https://github.com/Prajwal-Narayan/Contexara
7
+ Project-URL: Repository, https://github.com/Prajwal-Narayan/Contexara
8
+ Keywords: llm,memory,agentic-ai,retrieval,context
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: boto3>=1.34.0
12
+ Requires-Dist: python-dotenv>=1.0.0
13
+ Requires-Dist: sentence-transformers>=3.0.0
14
+ Requires-Dist: numpy>=1.26.0
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
17
+ Requires-Dist: build>=1.2.0; extra == "dev"
18
+
19
+ # Contexara
20
+
21
+ A CLI-first memory engine for LLM applications. Drop it into any AI system and it remembers what matters — across sessions, without a vector database, without infra.
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ pip install contexara
27
+ ```
28
+
29
+ ## How it works
30
+
31
+ Every conversation turn is analysed by an LLM (AWS Bedrock Haiku) and distilled into atomic facts. Facts are embedded with `all-MiniLM-L6-v2` and stored as BLOBs in a local SQLite file — no external services required.
32
+
33
+ Retrieval uses **Reciprocal Rank Fusion**: semantic similarity and keyword overlap are ranked independently then fused, so queries work whether you ask loosely or precisely.
34
+
35
+ Memories are never deleted. When the store exceeds a budget threshold, old memories are compressed into sharp facts by the LLM and the originals move to an archive table — permanently recoverable. Every compressed memory carries a `compression_level` and is penalised in retrieval accordingly. Raw memories always beat compressed ones when scores are close.
36
+
37
+ ## CLI
38
+
39
+ ```bash
40
+ contexara ask "what was I working on?"
41
+ contexara store "prefers Python for backend work"
42
+ contexara list
43
+ contexara search "tech stack"
44
+ contexara stats
45
+ contexara consolidate
46
+ contexara delete <id>
47
+ ```
48
+
49
+ ## Use in code
50
+
51
+ ```python
52
+ from contexara import ask, store, retrieve, ingest_turn
53
+
54
+ store("User prefers concise answers")
55
+ ingest_turn(user_text, assistant_text) # auto-extracts memories
56
+ answer = ask("what do I prefer?")
57
+ ```
58
+
59
+ ## Stack
60
+
61
+ - Storage: SQLite (embeddings as BLOBs, archive table included)
62
+ - Embeddings: `all-MiniLM-L6-v2` via sentence-transformers
63
+ - LLM: AWS Bedrock (Haiku) — extraction, compression, Q&A
64
+ - Retrieval: RRF hybrid search + source-aware ranking
65
+
66
+ ---
67
+
68
+ Built by [Prajwal Narayan](https://github.com/Prajwal-Narayan)
@@ -0,0 +1,50 @@
1
+ # Contexara
2
+
3
+ A CLI-first memory engine for LLM applications. Drop it into any AI system and it remembers what matters — across sessions, without a vector database, without infra.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install contexara
9
+ ```
10
+
11
+ ## How it works
12
+
13
+ Every conversation turn is analysed by an LLM (AWS Bedrock Haiku) and distilled into atomic facts. Facts are embedded with `all-MiniLM-L6-v2` and stored as BLOBs in a local SQLite file — no external services required.
14
+
15
+ Retrieval uses **Reciprocal Rank Fusion**: semantic similarity and keyword overlap are ranked independently then fused, so queries work whether you ask loosely or precisely.
16
+
17
+ Memories are never deleted. When the store exceeds a budget threshold, old memories are compressed into sharp facts by the LLM and the originals move to an archive table — permanently recoverable. Every compressed memory carries a `compression_level` and is penalised in retrieval accordingly. Raw memories always beat compressed ones when scores are close.
18
+
19
+ ## CLI
20
+
21
+ ```bash
22
+ contexara ask "what was I working on?"
23
+ contexara store "prefers Python for backend work"
24
+ contexara list
25
+ contexara search "tech stack"
26
+ contexara stats
27
+ contexara consolidate
28
+ contexara delete <id>
29
+ ```
30
+
31
+ ## Use in code
32
+
33
+ ```python
34
+ from contexara import ask, store, retrieve, ingest_turn
35
+
36
+ store("User prefers concise answers")
37
+ ingest_turn(user_text, assistant_text) # auto-extracts memories
38
+ answer = ask("what do I prefer?")
39
+ ```
40
+
41
+ ## Stack
42
+
43
+ - Storage: SQLite (embeddings as BLOBs, archive table included)
44
+ - Embeddings: `all-MiniLM-L6-v2` via sentence-transformers
45
+ - LLM: AWS Bedrock (Haiku) — extraction, compression, Q&A
46
+ - Retrieval: RRF hybrid search + source-aware ranking
47
+
48
+ ---
49
+
50
+ Built by [Prajwal Narayan](https://github.com/Prajwal-Narayan)
@@ -0,0 +1,11 @@
1
+ """Contexara public package API."""
2
+
3
+ from .enhance import enhance
4
+ from .ingest import ingest_turn
5
+ from .main import ask
6
+ from .retrieve import retrieve
7
+ from .store import clear_all, compress_old_memories, consolidate, delete_memory, get_stats, list_all, search_memories, store
8
+
9
+ __all__ = ["ask", "store", "list_all", "clear_all", "delete_memory", "search_memories", "get_stats", "consolidate", "compress_old_memories", "retrieve", "enhance", "ingest_turn"]
10
+
11
+ __version__ = "0.2.1"
@@ -0,0 +1,7 @@
1
+ """Module entrypoint for `python -m contexara`."""
2
+
3
+ from .cli import main
4
+
5
+
6
+ if __name__ == "__main__":
7
+ raise SystemExit(main())
@@ -0,0 +1,236 @@
1
+ """Command-line interface for Contexara."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from typing import Iterable
7
+
8
+ from . import __version__
9
+ from .main import ask
10
+ from .store import clear_all, consolidate, delete_memory, get_stats, list_all, search_memories, store
11
+
12
+
13
+ def _build_parser() -> argparse.ArgumentParser:
14
+ parser = argparse.ArgumentParser(
15
+ prog="contexara",
16
+ description="CLI-first memory engine for LLM context management.",
17
+ )
18
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
19
+
20
+ subparsers = parser.add_subparsers(dest="command")
21
+
22
+ subparsers.add_parser("chat", help="Start interactive chat mode.").set_defaults(func=_cmd_chat)
23
+
24
+ ask_parser = subparsers.add_parser("ask", help="Ask a memory-aware question.")
25
+ ask_parser.add_argument("query", nargs="+")
26
+ ask_parser.set_defaults(func=_cmd_ask)
27
+
28
+ store_parser = subparsers.add_parser("store", help="Store a memory string.")
29
+ store_parser.add_argument("content", nargs="+")
30
+ store_parser.set_defaults(func=_cmd_store)
31
+
32
+ subparsers.add_parser("list", help="List all stored memories.").set_defaults(func=_cmd_list)
33
+
34
+ delete_parser = subparsers.add_parser("delete", help="Delete a memory by ID.")
35
+ delete_parser.add_argument("id", type=int, help="Memory ID (from contexara list).")
36
+ delete_parser.set_defaults(func=_cmd_delete)
37
+
38
+ search_parser = subparsers.add_parser("search", help="Semantic search over memories.")
39
+ search_parser.add_argument("query", nargs="+")
40
+ search_parser.add_argument("--top", type=int, default=5, help="Number of results (default 5).")
41
+ search_parser.set_defaults(func=_cmd_search)
42
+
43
+ subparsers.add_parser("stats", help="Show memory statistics.").set_defaults(func=_cmd_stats)
44
+
45
+ subparsers.add_parser("consolidate", help="LLM-merge overlapping memories.").set_defaults(func=_cmd_consolidate)
46
+
47
+ clear_parser = subparsers.add_parser("clear", help="Delete all stored memories.")
48
+ clear_parser.add_argument("--yes", action="store_true", help="Skip confirmation.")
49
+ clear_parser.set_defaults(func=_cmd_clear)
50
+
51
+ return parser
52
+
53
+
54
+ def _join_parts(parts: Iterable[str]) -> str:
55
+ return " ".join(parts).strip()
56
+
57
+
58
+ def _print_memories_table(memories: list[dict]) -> None:
59
+ if not memories:
60
+ print("(no memories)")
61
+ return
62
+
63
+ id_w = max(len("ID"), max(len(str(m["id"])) for m in memories))
64
+ kind_w = max(len("KIND"), max(len(str(m.get("kind", ""))) for m in memories))
65
+ imp_w = max(len("IMP"), max(len(str(m.get("importance", ""))) for m in memories))
66
+ src_w = max(len("SRC"), max(len(str(m.get("source", "raw"))) for m in memories))
67
+ content_w = 60
68
+
69
+ header = f"{'ID':<{id_w}} {'KIND':<{kind_w}} {'IMP':<{imp_w}} {'SRC':<{src_w}} {'CONTENT':<{content_w}}"
70
+ print(header)
71
+ print("-" * len(header))
72
+ for m in memories:
73
+ content = m["content"]
74
+ if len(content) > content_w:
75
+ content = content[: content_w - 3] + "..."
76
+ print(f"{str(m['id']):<{id_w}} {str(m.get('kind','')):<{kind_w}} {str(m.get('importance','')):<{imp_w}} {str(m.get('source','raw')):<{src_w}} {content}")
77
+
78
+
79
+ def _cmd_chat(_args: argparse.Namespace) -> int:
80
+ print("\nContexara Chat (type 'exit' to quit)")
81
+ print("Commands: /store <text>, /list, /show <id>, /delete <id>, /search <query>, /stats, /consolidate, /clear\n")
82
+
83
+ while True:
84
+ user_input = input("You: ").strip()
85
+ if not user_input:
86
+ continue
87
+
88
+ if user_input.lower() == "exit":
89
+ print("Goodbye.")
90
+ return 0
91
+
92
+ if user_input.startswith("/store "):
93
+ store(user_input[7:].strip())
94
+ print("Memory stored.")
95
+ continue
96
+
97
+ if user_input == "/list":
98
+ _print_memories_table(list_all())
99
+ continue
100
+
101
+ if user_input.startswith("/delete "):
102
+ try:
103
+ mid = int(user_input[8:].strip())
104
+ if delete_memory(mid):
105
+ print(f"Memory {mid} deleted.")
106
+ else:
107
+ print(f"No memory with ID {mid}.")
108
+ except ValueError:
109
+ print("Usage: /delete <id>")
110
+ continue
111
+
112
+ if user_input.startswith("/search "):
113
+ query = user_input[8:].strip()
114
+ results = search_memories(query)
115
+ _print_memories_table(results)
116
+ continue
117
+
118
+ if user_input.startswith("/show "):
119
+ try:
120
+ mid = int(user_input[6:].strip())
121
+ match = next((m for m in list_all() if m["id"] == mid), None)
122
+ if match:
123
+ print(f"\nID : {match['id']}")
124
+ print(f"Kind : {match['kind']}")
125
+ print(f"Importance: {match['importance']}")
126
+ print(f"Source : {match.get('source', 'raw')}")
127
+ print(f"Level : {match.get('compression_level', 0)}")
128
+ print(f"Created : {match['created_at']}")
129
+ print(f"Content : {match['content']}\n")
130
+ else:
131
+ print(f"No memory with ID {mid}.")
132
+ except ValueError:
133
+ print("Usage: /show <id>")
134
+ continue
135
+
136
+ if user_input == "/consolidate":
137
+ print("Consolidating memories...")
138
+ result = consolidate()
139
+ print(f"Done. {result['before']} memories → {result['after']} memories.")
140
+ continue
141
+
142
+ if user_input == "/stats":
143
+ _print_stats(get_stats())
144
+ continue
145
+
146
+ if user_input == "/clear":
147
+ clear_all()
148
+ print("All memories cleared.")
149
+ continue
150
+
151
+ response = ask(user_input)
152
+ print(f"\nBot: {response}\n")
153
+
154
+ return 0
155
+
156
+
157
+ def _print_stats(stats: dict) -> None:
158
+ print(f"\nTotal memories : {stats['total']}")
159
+ print(f"Oldest : {stats['oldest'] or 'N/A'}")
160
+ print(f"Newest : {stats['newest'] or 'N/A'}")
161
+ print("\nBy kind:")
162
+ for kind, count in stats["by_kind"].items():
163
+ print(f" {kind:<12} {count}")
164
+ print("\nBy source:")
165
+ for source, count in stats.get("by_source", {}).items():
166
+ ss = stats.get("source_stats", {}).get(source, {})
167
+ avg_imp = ss.get("avg_importance", "")
168
+ avg_age = ss.get("avg_age_days", "")
169
+ print(f" {source:<12} {count:<6} avg_importance={avg_imp} avg_age={avg_age}d")
170
+ print()
171
+
172
+
173
+ def _cmd_ask(args: argparse.Namespace) -> int:
174
+ print(ask(_join_parts(args.query)))
175
+ return 0
176
+
177
+
178
+ def _cmd_store(args: argparse.Namespace) -> int:
179
+ store(_join_parts(args.content))
180
+ print("Memory stored.")
181
+ return 0
182
+
183
+
184
+ def _cmd_list(_args: argparse.Namespace) -> int:
185
+ _print_memories_table(list_all())
186
+ return 0
187
+
188
+
189
+ def _cmd_delete(args: argparse.Namespace) -> int:
190
+ if delete_memory(args.id):
191
+ print(f"Memory {args.id} deleted.")
192
+ else:
193
+ print(f"No memory with ID {args.id}.")
194
+ return 0
195
+
196
+
197
+ def _cmd_search(args: argparse.Namespace) -> int:
198
+ results = search_memories(_join_parts(args.query), top_k=args.top)
199
+ _print_memories_table(results)
200
+ return 0
201
+
202
+
203
+ def _cmd_stats(_args: argparse.Namespace) -> int:
204
+ _print_stats(get_stats())
205
+ return 0
206
+
207
+
208
+ def _cmd_consolidate(_args: argparse.Namespace) -> int:
209
+ print("Consolidating memories...")
210
+ result = consolidate()
211
+ print(f"Done. {result['before']} memories → {result['after']} memories.")
212
+ return 0
213
+
214
+
215
+ def _cmd_clear(args: argparse.Namespace) -> int:
216
+ if not args.yes:
217
+ confirm = input("Delete all memories? [y/N]: ").strip().lower()
218
+ if confirm not in {"y", "yes"}:
219
+ print("Cancelled.")
220
+ return 0
221
+ clear_all()
222
+ print("All memories cleared.")
223
+ return 0
224
+
225
+
226
+ def main() -> int:
227
+ parser = _build_parser()
228
+ args = parser.parse_args()
229
+ if not args.command:
230
+ parser.print_help()
231
+ return 0
232
+ return args.func(args)
233
+
234
+
235
+ if __name__ == "__main__":
236
+ raise SystemExit(main())
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ from functools import lru_cache
5
+
6
+ import numpy as np
7
+
8
+ _MODEL_NAME = "all-MiniLM-L6-v2"
9
+
10
+
11
+ @lru_cache(maxsize=1)
12
+ def _get_model():
13
+ import logging
14
+ from sentence_transformers import SentenceTransformer
15
+ logging.getLogger("transformers.modeling_utils").setLevel(logging.ERROR)
16
+ return SentenceTransformer(_MODEL_NAME)
17
+
18
+
19
+ def embed(text: str) -> np.ndarray:
20
+ """Return a normalized float32 embedding vector for text."""
21
+ model = _get_model()
22
+ vector = model.encode(text, normalize_embeddings=True)
23
+ return vector.astype(np.float32)
24
+
25
+
26
+ def serialize(vector: np.ndarray) -> bytes:
27
+ buf = io.BytesIO()
28
+ np.save(buf, vector)
29
+ return buf.getvalue()
30
+
31
+
32
+ def deserialize(blob: bytes) -> np.ndarray:
33
+ buf = io.BytesIO(blob)
34
+ return np.load(buf)
35
+
36
+
37
+ def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
38
+ """Cosine similarity for pre-normalized vectors (dot product suffices)."""
39
+ return float(np.dot(a, b))
@@ -0,0 +1,30 @@
1
+ def enhance(query: str, memories: list[str]) -> str:
2
+ """
3
+ Build an enhanced prompt by injecting relevant memories.
4
+ """
5
+
6
+ if not query or not query.strip():
7
+ raise ValueError("Query cannot be empty")
8
+
9
+ query = query.strip()
10
+
11
+ # Format memory block
12
+ if memories:
13
+ memory_lines = "\n".join(f"- {m}" for m in memories)
14
+
15
+ context_block = f"""You are an AI assistant with memory.
16
+
17
+ Relevant context:
18
+ {memory_lines}
19
+
20
+ """
21
+ else:
22
+ context_block = "You are an AI assistant.\n\n"
23
+
24
+ # Final prompt
25
+ prompt = f"""{context_block}User:
26
+ {query}
27
+
28
+ Assistant:"""
29
+
30
+ return prompt