cli-memory-os 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.
Files changed (50) hide show
  1. cli/__init__.py +1 -0
  2. cli/commands/__init__.py +1 -0
  3. cli/commands/benchmark.py +151 -0
  4. cli/commands/config_cmd.py +57 -0
  5. cli/commands/doctor.py +61 -0
  6. cli/commands/export.py +106 -0
  7. cli/commands/import_cmd.py +143 -0
  8. cli/commands/init.py +205 -0
  9. cli/commands/logs.py +38 -0
  10. cli/commands/monitor.py +63 -0
  11. cli/commands/plugins.py +37 -0
  12. cli/commands/start.py +24 -0
  13. cli/commands/status.py +92 -0
  14. cli/commands/stop.py +19 -0
  15. cli/commands/version.py +25 -0
  16. cli/commands/workspace.py +154 -0
  17. cli/main.py +496 -0
  18. cli/parser.py +188 -0
  19. cli_memory_os-0.1.0.dist-info/METADATA +231 -0
  20. cli_memory_os-0.1.0.dist-info/RECORD +50 -0
  21. cli_memory_os-0.1.0.dist-info/WHEEL +5 -0
  22. cli_memory_os-0.1.0.dist-info/entry_points.txt +2 -0
  23. cli_memory_os-0.1.0.dist-info/licenses/LICENSE +21 -0
  24. cli_memory_os-0.1.0.dist-info/top_level.txt +6 -0
  25. connectors/__init__.py +1 -0
  26. connectors/base.py +39 -0
  27. connectors/github.py +273 -0
  28. connectors/gmail.py +116 -0
  29. connectors/notion.py +156 -0
  30. connectors/registry.py +60 -0
  31. core/__init__.py +1 -0
  32. core/chunker.py +136 -0
  33. core/context_builder.py +407 -0
  34. core/embedder.py +42 -0
  35. core/llm.py +301 -0
  36. core/vector_store.py +629 -0
  37. infrastructure/__init__.py +1 -0
  38. infrastructure/compose.py +136 -0
  39. infrastructure/config.py +280 -0
  40. infrastructure/docker.py +61 -0
  41. infrastructure/health.py +338 -0
  42. infrastructure/observability.py +160 -0
  43. infrastructure/workspace.py +152 -0
  44. models/__init__.py +1 -0
  45. models/memory.py +28 -0
  46. storage/__init__.py +1 -0
  47. storage/db.py +528 -0
  48. storage/graph.py +324 -0
  49. storage/schema.sql +57 -0
  50. storage/tech_detector.py +117 -0
@@ -0,0 +1,407 @@
1
+ import logging
2
+ import re
3
+
4
+ logger = logging.getLogger("context_builder")
5
+
6
+ class ContextBuilder:
7
+ """Handles query classification, merging, deduplicating, scoring, and enforcing size constraints on RAG contexts."""
8
+
9
+ def __init__(self, max_context_chars: int = 12000, max_doc_chars: int = 1000, max_graph_relationships: int = 10, max_prompt_tokens: int = 6000, max_chunks: int = 8):
10
+ self.max_context_chars = max_context_chars
11
+ self.max_doc_chars = max_doc_chars
12
+ self.max_graph_relationships = max_graph_relationships
13
+ self.max_prompt_tokens = max_prompt_tokens
14
+ self.max_chunks = max_chunks
15
+
16
+ @staticmethod
17
+ def classify_query(query: str, repo_filter = None) -> str:
18
+ """
19
+ Classify the user query to optimize retrieval precision.
20
+ Rule-based, no LLM required.
21
+ """
22
+ query_lower = query.lower()
23
+
24
+ # 1. Email Question
25
+ email_keywords = ["email", "emails", "inbox", "message", "messages", "sent from", "received from", "mail", "mails", "gmail"]
26
+ if any(k in query_lower for k in email_keywords):
27
+ return "Email Question"
28
+
29
+ # 2. Technology Question
30
+ tech_triggers = [
31
+ "use", "uses", "using", "built with", "written in", "framework", "technology", "technologies", "tech stack",
32
+ "database", "databases", "language", "languages", "library", "libraries", "techs"
33
+ ]
34
+ known_techs = ["python", "javascript", "typescript", "react", "node", "express", "mongodb", "fastapi", "postgresql", "tailwind", "docker", "kafka", "redis", "plotly", "gemini", "groq", "next.js", "firebase", "sqlite", "neo4j", "qdrant", "composio", "langchain", "sentence-transformer", "sentence-transformers"]
35
+
36
+ has_tech_trigger = any(t in query_lower for t in tech_triggers)
37
+ has_known_tech = any(re.search(r'\b' + re.escape(tech) + r'\b', query_lower) for tech in known_techs)
38
+
39
+ if (has_tech_trigger or "which" in query_lower) and has_known_tech:
40
+ if "repositories" in query_lower or "projects" in query_lower or "which" in query_lower or "what" in query_lower:
41
+ return "Technology Question"
42
+
43
+ # 3. Repository Question
44
+ if repo_filter:
45
+ return "Repository Question"
46
+
47
+ # 4. Documentation Question
48
+ doc_keywords = ["readme", "documentation", "document", "documents", "architecture", "design", "pyproject.toml", "package.json", "requirements.txt", "file", "files"]
49
+ if any(dk in query_lower for dk in doc_keywords):
50
+ return "Documentation Question"
51
+
52
+ # 5. Cross Repository Question
53
+ if "cross repository" in query_lower or "multiple repositories" in query_lower or "across all projects" in query_lower:
54
+ return "Cross Repository Question"
55
+
56
+ # Default
57
+ return "General Knowledge Question"
58
+
59
+ def estimate_tokens(self, text: str) -> int:
60
+ """Estimate token count of a string (heuristic: ~4 characters per token)."""
61
+ return len(text) // 4
62
+
63
+ def is_nearly_identical(self, text1: str, text2: str) -> bool:
64
+ """Check if two chunks have high word overlap (>75% identical words) to filter out duplicates."""
65
+ words1 = set(re.findall(r'\w+', text1.lower()))
66
+ words2 = set(re.findall(r'\w+', text2.lower()))
67
+ if not words1 or not words2:
68
+ return False
69
+ intersection = words1.intersection(words2)
70
+ overlap1 = len(intersection) / len(words1)
71
+ overlap2 = len(intersection) / len(words2)
72
+ return overlap1 > 0.75 or overlap2 > 0.75
73
+
74
+ def build_context(self, question: str, vector_results: list, keyword_results: list, graph_results: list, repo_filter = None, query_class: str = "General Knowledge Question") -> tuple:
75
+ """
76
+ Merge, deduplicate, score, and format context from vector, keyword, and graph sources.
77
+ Returns (formatted_chunks, sources_list, repos_list, num_vector, num_keyword, num_graph, after_dedup_count)
78
+ """
79
+ query_lower = question.lower()
80
+ stop_words = {"a", "an", "the", "in", "of", "and", "or", "to", "for", "with", "is", "at", "on", "by", "what", "which", "does", "use", "how", "tell", "me", "about"}
81
+ query_terms = [t for t in query_lower.split() if t not in stop_words and len(t) > 1]
82
+ if not query_terms:
83
+ query_terms = [t for t in query_lower.split() if len(t) > 0]
84
+
85
+ # Candidate Map to merge vector and keyword results
86
+ candidates = {}
87
+
88
+ # 1. Parse keyword results
89
+ num_keyword_raw = len(keyword_results)
90
+ for item in keyword_results:
91
+ t = item["type"]
92
+ if t == "repository":
93
+ key = f"repository:{item['repo_name'].lower()}"
94
+ candidates[key] = {
95
+ "type": "repository",
96
+ "repo_name": item["repo_name"],
97
+ "language": item.get("language") or "",
98
+ "description": item.get("description") or "",
99
+ "semantic_similarity": 0.0,
100
+ "keyword_match_val": 1.0
101
+ }
102
+ elif t == "document":
103
+ key = f"document:{item['repo_name'].lower()}:{item['file_name'].lower()}"
104
+ candidates[key] = {
105
+ "type": "document",
106
+ "repo_name": item["repo_name"],
107
+ "file_name": item["file_name"],
108
+ "content": item["content"] or "",
109
+ "semantic_similarity": 0.0,
110
+ "keyword_match_val": 1.0
111
+ }
112
+ elif t == "email":
113
+ key = f"email:{item['subject'].lower()}"
114
+ candidates[key] = {
115
+ "type": "email",
116
+ "subject": item["subject"],
117
+ "sender": item["sender"],
118
+ "snippet": item["snippet"] or "",
119
+ "semantic_similarity": 0.0,
120
+ "keyword_match_val": 1.0
121
+ }
122
+
123
+ # 2. Parse vector results
124
+ num_vector_raw = len(vector_results)
125
+ for sem in vector_results:
126
+ source = sem["source_type"]
127
+ if source == "repository":
128
+ key = f"repository:{sem['repository_name'].lower()}"
129
+ if key not in candidates:
130
+ candidates[key] = {
131
+ "type": "repository",
132
+ "repo_name": sem["repository_name"],
133
+ "description": sem["chunk_text"] or "",
134
+ "semantic_similarity": sem["score"],
135
+ "keyword_match_val": 0.0
136
+ }
137
+ else:
138
+ candidates[key]["semantic_similarity"] = max(candidates[key]["semantic_similarity"], sem["score"])
139
+ elif source == "document":
140
+ key = f"document:{sem['repository_name'].lower()}:{sem['document_name'].lower()}"
141
+ if key not in candidates:
142
+ candidates[key] = {
143
+ "type": "document",
144
+ "repo_name": sem["repository_name"],
145
+ "file_name": sem["document_name"],
146
+ "content": sem["chunk_text"] or "",
147
+ "semantic_similarity": sem["score"],
148
+ "keyword_match_val": 0.0
149
+ }
150
+ else:
151
+ if sem["score"] > candidates[key]["semantic_similarity"]:
152
+ candidates[key]["content"] = sem["chunk_text"]
153
+ candidates[key]["semantic_similarity"] = max(candidates[key]["semantic_similarity"], sem["score"])
154
+ elif source == "email":
155
+ key = f"email:{sem['document_name'].lower()}"
156
+ if key not in candidates:
157
+ candidates[key] = {
158
+ "type": "email",
159
+ "subject": sem["document_name"],
160
+ "sender": "Gmail Index",
161
+ "snippet": sem["chunk_text"] or "",
162
+ "semantic_similarity": sem["score"],
163
+ "keyword_match_val": 0.0
164
+ }
165
+ else:
166
+ if sem["score"] > candidates[key]["semantic_similarity"]:
167
+ candidates[key]["snippet"] = sem["chunk_text"]
168
+ candidates[key]["semantic_similarity"] = max(candidates[key]["semantic_similarity"], sem["score"])
169
+
170
+ # 3. Source Priority Checks (TASK 3)
171
+ # Check if any documentation exists (either document or repository)
172
+ has_documentation = any(cand["type"] in ["document", "repository"] for cand in candidates.values())
173
+
174
+ # Discard emails if the query does NOT explicitly ask about emails AND documentation exists
175
+ is_email_query = (query_class == "Email Question")
176
+ if not is_email_query and has_documentation:
177
+ # Remove all emails
178
+ candidates = {k: v for k, v in candidates.items() if v["type"] != "email"}
179
+
180
+ # 4. Calculate Hybrid Score for each candidate (TASK 4)
181
+ scored_candidates = []
182
+ for key, cand in candidates.items():
183
+ sim = cand["semantic_similarity"]
184
+ repo_name = cand.get("repo_name") or ""
185
+ file_name = cand.get("file_name") or ""
186
+
187
+ # Repository match boost (0.20)
188
+ repo_match_val = 0.0
189
+ if repo_filter:
190
+ if isinstance(repo_filter, list):
191
+ if repo_name in repo_filter:
192
+ repo_match_val = 1.0
193
+ elif repo_name.lower() == repo_filter.lower():
194
+ repo_match_val = 1.0
195
+
196
+ # README Bonus (0.10)
197
+ readme_bonus_val = 1.0 if "readme" in file_name.lower() else 0.0
198
+
199
+ # Graph relevance boost (0.10)
200
+ graph_relevance_val = 0.0
201
+ if graph_results:
202
+ for rel_desc in graph_results:
203
+ if repo_name and repo_name.lower() in rel_desc.lower():
204
+ graph_relevance_val = 1.0
205
+ break
206
+ if file_name and file_name.lower() in rel_desc.lower():
207
+ graph_relevance_val = 1.0
208
+ break
209
+
210
+ # Keyword Match (0.05)
211
+ keyword_match_val = cand.get("keyword_match_val", 0.0)
212
+ if keyword_match_val == 0.0:
213
+ text_to_search = ""
214
+ if cand["type"] == "repository":
215
+ text_to_search = f"{cand.get('repo_name') or ''} {cand.get('description') or ''}"
216
+ elif cand["type"] == "document":
217
+ text_to_search = f"{cand.get('file_name') or ''} {cand.get('content') or ''}"
218
+ elif cand["type"] == "email":
219
+ text_to_search = f"{cand.get('subject') or ''} {cand.get('snippet') or ''}"
220
+ text_to_search_lower = text_to_search.lower()
221
+ if any(term in text_to_search_lower for term in query_terms):
222
+ keyword_match_val = 1.0
223
+
224
+ # Documentation Bonus (0.05)
225
+ doc_bonus_val = 0.0
226
+ if cand["type"] == "document":
227
+ file_lower = file_name.lower()
228
+ if "readme" in file_lower:
229
+ doc_bonus_val = 1.0
230
+ elif "architecture" in file_lower or "design" in file_lower or "structure" in file_lower:
231
+ doc_bonus_val = 1.0
232
+ elif file_lower.endswith(".md"):
233
+ doc_bonus_val = 1.0
234
+ elif file_name in ["pyproject.toml", "package.json", "requirements.txt"]:
235
+ doc_bonus_val = 0.6
236
+ elif file_lower.endswith((".py", ".js", ".ts", ".go", ".rs", ".java", ".c", ".cpp")):
237
+ doc_bonus_val = 0.2
238
+ elif cand["type"] == "repository":
239
+ doc_bonus_val = 0.5
240
+
241
+ # Calculate final score
242
+ # Semantic Similarity 0.50
243
+ # Repository Match 0.20
244
+ # README Bonus 0.10
245
+ # Graph Relevance 0.10
246
+ # Keyword Match 0.05
247
+ # Documentation Bonus 0.05
248
+ final_score = round(
249
+ (0.50 * sim) +
250
+ (0.20 * repo_match_val) +
251
+ (0.10 * readme_bonus_val) +
252
+ (0.10 * graph_relevance_val) +
253
+ (0.05 * keyword_match_val) +
254
+ (0.05 * doc_bonus_val),
255
+ 4
256
+ )
257
+
258
+ cand["score"] = final_score
259
+ scored_candidates.append(cand)
260
+
261
+ # Sort candidates by score descending
262
+ scored_candidates.sort(key=lambda x: (-x["score"], x.get("repo_name") or x.get("subject") or ""))
263
+
264
+ # 5. Deduplication and Context Filtering (TASK 5)
265
+ deduplicated_candidates = []
266
+ for cand in scored_candidates:
267
+ cand_text = ""
268
+ if cand["type"] == "repository":
269
+ cand_text = cand.get("description") or ""
270
+ elif cand["type"] == "document":
271
+ cand_text = cand.get("content") or ""
272
+ elif cand["type"] == "email":
273
+ cand_text = cand.get("snippet") or ""
274
+
275
+ # Check if nearly identical to any already included chunk
276
+ is_dup = False
277
+ for doc in deduplicated_candidates:
278
+ doc_text = ""
279
+ if doc["type"] == "repository":
280
+ doc_text = doc.get("description") or ""
281
+ elif doc["type"] == "document":
282
+ doc_text = doc.get("content") or ""
283
+ elif doc["type"] == "email":
284
+ doc_text = doc.get("snippet") or ""
285
+
286
+ if self.is_nearly_identical(cand_text, doc_text):
287
+ is_dup = True
288
+ break
289
+
290
+ if not is_dup:
291
+ deduplicated_candidates.append(cand)
292
+
293
+ after_dedup_count = len(deduplicated_candidates)
294
+
295
+ # Enforce context limits (max 8-10 chunks or max 12000 chars)
296
+ limited_graph_results = graph_results[:self.max_graph_relationships]
297
+ num_graph = len(limited_graph_results)
298
+
299
+ formatted_chunks = []
300
+ sources = []
301
+ repos = []
302
+ char_count = 0
303
+ chunk_count = 0
304
+
305
+ # Build final candidates checking both chunk limit and char limit
306
+ # Always keep highest ranked chunks
307
+ for cand in deduplicated_candidates:
308
+ if chunk_count >= self.max_chunks:
309
+ break
310
+
311
+ # Format and truncate single chunk
312
+ if cand["type"] == "repository":
313
+ repo_name = cand["repo_name"]
314
+ desc = cand.get("description") or ""
315
+ if len(desc) > self.max_doc_chars:
316
+ desc = desc[:self.max_doc_chars] + "... [TRUNCATED]"
317
+ chunk_str = f"Repository: {repo_name}\nDescription: {desc}\nLanguage: {cand.get('language') or ''}"
318
+
319
+ repos.append(repo_name)
320
+ sources.append(f"Repository metadata for '{repo_name}'")
321
+ elif cand["type"] == "document":
322
+ repo_name = cand["repo_name"]
323
+ file_name = cand["file_name"]
324
+ content = cand.get("content") or ""
325
+ if len(content) > self.max_doc_chars:
326
+ content = content[:self.max_doc_chars] + "... [TRUNCATED]"
327
+ chunk_str = f"Repository: {repo_name}\nDocument: {file_name}\nContent: {content}"
328
+
329
+ repos.append(repo_name)
330
+ sources.append(f"{repo_name}/{file_name}")
331
+ elif cand["type"] == "email":
332
+ subject = cand["subject"]
333
+ sender = cand["sender"]
334
+ snippet = cand.get("snippet") or ""
335
+ if len(snippet) > self.max_doc_chars:
336
+ snippet = snippet[:self.max_doc_chars] + "... [TRUNCATED]"
337
+ chunk_str = f"Email Subject: {subject}\nFrom: {sender}\nContent Snippet: {snippet}"
338
+
339
+ sources.append(f"Email: {subject}")
340
+
341
+ # Check if adding this chunk exceeds character limit
342
+ projected_chars = char_count + len(chunk_str) + 4
343
+ if projected_chars > self.max_context_chars and chunk_count > 0:
344
+ # Character limit exceeded
345
+ break
346
+
347
+ formatted_chunks.append({
348
+ "text": chunk_str,
349
+ "score": cand["score"],
350
+ "type": cand["type"]
351
+ })
352
+ char_count += len(chunk_str) + 4
353
+ chunk_count += 1
354
+
355
+ # Append limited graph results (max graph relationships)
356
+ for rel_desc in limited_graph_results:
357
+ chunk_str = f"Knowledge Graph Relationship: {rel_desc}"
358
+
359
+ projected_chars = char_count + len(chunk_str) + 4
360
+ if projected_chars > self.max_context_chars:
361
+ break
362
+
363
+ if "Repository '" in rel_desc:
364
+ parts = rel_desc.split("Repository '")
365
+ if len(parts) > 1:
366
+ r_name = parts[1].split("'")[0]
367
+ repos.append(r_name)
368
+ sources.append("Knowledge Graph")
369
+ formatted_chunks.append({
370
+ "text": chunk_str,
371
+ "score": 0.05,
372
+ "type": "graph"
373
+ })
374
+ char_count += len(chunk_str) + 4
375
+
376
+ sources = sorted(list(set(sources)))
377
+ repos = sorted(list(set(repos)))
378
+
379
+ return formatted_chunks, sources, repos, num_vector_raw, num_keyword_raw, num_graph, after_dedup_count
380
+
381
+ def trim_context_to_limit(self, system_prompt: str, user_prompt_template: str, formatted_chunks: list) -> tuple:
382
+ """Trims lowest-ranked context chunks iteratively if total estimated tokens exceed self.max_prompt_tokens."""
383
+ active_chunks = list(formatted_chunks)
384
+
385
+ while len(active_chunks) > 0:
386
+ merged_context = "\n\n".join([c["text"] for c in active_chunks])
387
+ full_prompt_text = system_prompt + user_prompt_template.replace("{context}", merged_context)
388
+ est_tokens = self.estimate_tokens(full_prompt_text)
389
+
390
+ if est_tokens <= self.max_prompt_tokens:
391
+ return merged_context, len(active_chunks)
392
+
393
+ # Over limit, remove lowest-ranked chunk
394
+ min_idx = -1
395
+ min_score = float('inf')
396
+ for i, c in enumerate(active_chunks):
397
+ if c["score"] < min_score:
398
+ min_score = c["score"]
399
+ min_idx = i
400
+
401
+ if min_idx != -1:
402
+ removed = active_chunks.pop(min_idx)
403
+ logger.info(f"Context builder trimmed chunk of type '{removed['type']}' with score {removed['score']} due to token limit.")
404
+ else:
405
+ break
406
+
407
+ return "", 0
core/embedder.py ADDED
@@ -0,0 +1,42 @@
1
+ import os
2
+
3
+ class Embedder:
4
+ _model = None
5
+
6
+ def __init__(self):
7
+ self.model_name = "all-MiniLM-L6-v2"
8
+
9
+ @property
10
+ def model(self):
11
+ if Embedder._model is None:
12
+ # Load SentenceTransformer model
13
+ from sentence_transformers import SentenceTransformer
14
+ try:
15
+ Embedder._model = SentenceTransformer(self.model_name)
16
+ except Exception as e:
17
+ # If network fails or times out, try loading from local cache only
18
+ try:
19
+ Embedder._model = SentenceTransformer(self.model_name, local_files_only=True)
20
+ except Exception:
21
+ raise e
22
+ return Embedder._model
23
+
24
+ def fit(self, documents=None):
25
+ # SentenceTransformer all-MiniLM-L6-v2 is pre-trained, so this is a no-op
26
+ pass
27
+
28
+ def embed_documents(self, texts: list) -> list:
29
+ if not texts:
30
+ return []
31
+ embeddings = self.model.encode(texts, convert_to_numpy=True)
32
+ return [e.tolist() for e in embeddings]
33
+
34
+ def embed_query(self, text: str) -> list:
35
+ embedding = self.model.encode(text, convert_to_numpy=True)
36
+ return embedding.tolist()
37
+
38
+ def dimension(self) -> int:
39
+ return 384
40
+
41
+ def version(self) -> str:
42
+ return "all-MiniLM-L6-v2-384"