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
storage/graph.py ADDED
@@ -0,0 +1,324 @@
1
+ import os
2
+ import logging
3
+ from neo4j import GraphDatabase
4
+ from storage.db import (
5
+ insert_fallback_node,
6
+ insert_fallback_relationship,
7
+ get_fallback_relationships,
8
+ clear_fallback_graph,
9
+ get_all_repositories,
10
+ get_all_documents,
11
+ get_all_emails,
12
+ get_connection
13
+ )
14
+ from storage.tech_detector import detect_tech_for_repo
15
+
16
+ logger = logging.getLogger("graph_store")
17
+
18
+ class GraphStore:
19
+ _driver = None
20
+ _use_fallback = False
21
+
22
+ def __init__(self):
23
+ """Initialise driver for Neo4j knowledge graph, falling back to local SQLite if unavailable."""
24
+ if GraphStore._driver is None and not GraphStore._use_fallback:
25
+ uri = os.getenv("NEO4J_URI", "bolt://localhost:7687")
26
+ user = os.getenv("NEO4J_USER", "neo4j")
27
+ password = os.getenv("NEO4J_PASSWORD", "password")
28
+
29
+ try:
30
+ driver = GraphDatabase.driver(uri, auth=(user, password))
31
+ driver.verify_connectivity()
32
+ GraphStore._driver = driver
33
+ logger.info("Connected to Neo4j database successfully.")
34
+ print("Neo4j connection successful.")
35
+ except Exception as e:
36
+ GraphStore._use_fallback = True
37
+ logger.warning(f"Neo4j connectivity check failed: {e}. Falling back to SQLite graph storage.")
38
+ print("Neo4j not available. Using local SQLite Graph storage.")
39
+
40
+ @property
41
+ def driver(self):
42
+ """Get the active Neo4j driver client instance."""
43
+ return GraphStore._driver
44
+
45
+ @property
46
+ def is_fallback(self) -> bool:
47
+ """Boolean flag indicating whether the SQLite fallback graph engine is active."""
48
+ return GraphStore._use_fallback
49
+
50
+ def close(self):
51
+ """Close the active Neo4j driver connection cleanly."""
52
+ if GraphStore._driver:
53
+ try:
54
+ GraphStore._driver.close()
55
+ except Exception:
56
+ pass
57
+ GraphStore._driver = None
58
+
59
+ def clear_graph(self):
60
+ """Delete all nodes and relationships from both active and fallback graphs."""
61
+ if self.is_fallback:
62
+ clear_fallback_graph()
63
+ return
64
+
65
+ with self.driver.session() as session:
66
+ session.run("MATCH (n) DETACH DELETE n")
67
+
68
+ def insert_node(self, node_id: str, label: str, name: str):
69
+ """Merge a new node with unique node_id and name into the graph database."""
70
+ if self.is_fallback:
71
+ insert_fallback_node(node_id, label, name)
72
+ return
73
+
74
+ with self.driver.session() as session:
75
+ query = f"MERGE (n:{label} {{id: $id}}) SET n.name = $name"
76
+ session.run(query, id=node_id, name=name)
77
+
78
+ def insert_relationship(self, rel_id: str, source_id: str, source_label: str, target_id: str, target_label: str, rel_type: str):
79
+ """Merge a new directed relationship of rel_type between source and target nodes."""
80
+ if self.is_fallback:
81
+ insert_fallback_relationship(rel_id, source_id, target_id, rel_type)
82
+ return
83
+
84
+ with self.driver.session() as session:
85
+ query = (
86
+ f"MATCH (s:{source_label} {{id: $source_id}}) "
87
+ f"MATCH (t:{target_label} {{id: $target_id}}) "
88
+ f"MERGE (s)-[r:{rel_type}]->(t) "
89
+ f"SET r.id = $rel_id"
90
+ )
91
+ session.run(query, source_id=source_id, target_id=target_id, rel_id=rel_id)
92
+
93
+ def lookup_relationships(self, entity_name: str) -> list:
94
+ """Find nodes matching entity_name (case-insensitive substring) and return descriptions of their relationships."""
95
+ descriptions = []
96
+ entity_lower = entity_name.lower()
97
+
98
+ if self.is_fallback:
99
+ # First, find nodes in SQLite matching name
100
+ conn = get_connection()
101
+ cursor = conn.cursor()
102
+ cursor.execute("SELECT id, label, name FROM graph_nodes WHERE LOWER(name) LIKE ?", (f"%{entity_lower}%",))
103
+ nodes = cursor.fetchall()
104
+ conn.close()
105
+
106
+ for node_id, label, name in nodes:
107
+ rels = get_fallback_relationships(node_id)
108
+ for r in rels:
109
+ desc = f"{r['source_label']} '{r['source_name']}' {r['type']} {r['target_label']} '{r['target_name']}'"
110
+ descriptions.append(desc)
111
+ return list(set(descriptions))
112
+
113
+ # Query Neo4j
114
+ with self.driver.session() as session:
115
+ query = (
116
+ "MATCH (n) WHERE toLower(n.name) CONTAINS $entity "
117
+ "MATCH (n)-[r]-(m) "
118
+ "RETURN labels(n)[0] AS n_label, n.name AS n_name, type(r) AS rel_type, labels(m)[0] AS m_label, m.name AS m_name, startNode(r) = n AS is_outgoing"
119
+ )
120
+ result = session.run(query, entity=entity_lower)
121
+ for record in result:
122
+ n_label = record["n_label"] or "Node"
123
+ n_name = record["n_name"] or ""
124
+ rel_type = record["rel_type"] or ""
125
+ m_label = record["m_label"] or "Node"
126
+ m_name = record["m_name"] or ""
127
+
128
+ if record["is_outgoing"]:
129
+ desc = f"{n_label} '{n_name}' {rel_type} {m_label} '{m_name}'"
130
+ else:
131
+ desc = f"{m_label} '{m_name}' {rel_type} {n_label} '{n_name}'"
132
+ descriptions.append(desc)
133
+ return list(set(descriptions))
134
+
135
+ def get_node_relationships(self, label: str, name: str) -> list:
136
+ """Find relationships for a specific node by its label and name, case-insensitively."""
137
+ descriptions = []
138
+ name_lower = name.lower()
139
+
140
+ if self.is_fallback:
141
+ conn = get_connection()
142
+ cursor = conn.cursor()
143
+ # Find the node matching label and name case-insensitively
144
+ cursor.execute(
145
+ "SELECT id, label, name FROM graph_nodes WHERE LOWER(label) = LOWER(?) AND LOWER(name) = LOWER(?)",
146
+ (label, name)
147
+ )
148
+ node = cursor.fetchone()
149
+ if not node:
150
+ # If exact name is not found, try containing it
151
+ cursor.execute(
152
+ "SELECT id, label, name FROM graph_nodes WHERE LOWER(label) = LOWER(?) AND LOWER(name) LIKE ?",
153
+ (label, f"%{name_lower}%")
154
+ )
155
+ nodes = cursor.fetchall()
156
+ else:
157
+ nodes = [node]
158
+
159
+ for node_id, n_lbl, n_nm in nodes:
160
+ rels = get_fallback_relationships(node_id)
161
+ for r in rels:
162
+ desc = f"{r['source_label']} '{r['source_name']}' {r['type']} {r['target_label']} '{r['target_name']}'"
163
+ descriptions.append(desc)
164
+ conn.close()
165
+ return list(set(descriptions))
166
+
167
+ # Query Neo4j
168
+ with self.driver.session() as session:
169
+ query = (
170
+ f"MATCH (n:{label}) WHERE toLower(n.name) = $name "
171
+ "MATCH (n)-[r]-(m) "
172
+ "RETURN labels(n)[0] AS n_label, n.name AS n_name, type(r) AS rel_type, labels(m)[0] AS m_label, m.name AS m_name, startNode(r) = n AS is_outgoing"
173
+ )
174
+ result = session.run(query, name=name_lower)
175
+ records = list(result)
176
+ if not records:
177
+ query_contains = (
178
+ f"MATCH (n:{label}) WHERE toLower(n.name) CONTAINS $name "
179
+ "MATCH (n)-[r]-(m) "
180
+ "RETURN labels(n)[0] AS n_label, n.name AS n_name, type(r) AS rel_type, labels(m)[0] AS m_label, m.name AS m_name, startNode(r) = n AS is_outgoing"
181
+ )
182
+ result_contains = session.run(query_contains, name=name_lower)
183
+ records = list(result_contains)
184
+
185
+ for record in records:
186
+ n_label = record["n_label"] or "Node"
187
+ n_name = record["n_name"] or ""
188
+ rel_type = record["rel_type"] or ""
189
+ m_label = record["m_label"] or "Node"
190
+ m_name = record["m_name"] or ""
191
+
192
+ if record["is_outgoing"]:
193
+ desc = f"{n_label} '{n_name}' {rel_type} {m_label} '{m_name}'"
194
+ else:
195
+ desc = f"{m_label} '{m_name}' {rel_type} {n_label} '{n_name}'"
196
+ descriptions.append(desc)
197
+ return list(set(descriptions))
198
+
199
+ def extract_and_sync_graph(self, repo_names=None):
200
+ """Extract entities and relationships from SQLite and sync them to Neo4j/Fallback Graph."""
201
+ import time
202
+ import logging
203
+ logger = logging.getLogger("graph_store")
204
+
205
+ logger.info("Starting graph synchronization process...")
206
+ start_time = time.perf_counter()
207
+
208
+ print("Extracting entities and relationships to Graph...")
209
+
210
+ if repo_names is None:
211
+ self.clear_graph()
212
+ else:
213
+ # Incremental clear
214
+ for r_name in repo_names:
215
+ if r_name == "__emails__":
216
+ if self.is_fallback:
217
+ from storage.db import get_connection
218
+ conn = get_connection()
219
+ cursor = conn.cursor()
220
+ cursor.execute("DELETE FROM graph_relationships WHERE source_id LIKE 'Email:%' OR target_id LIKE 'Email:%' OR source_id LIKE 'User:%' OR target_id LIKE 'User:%'")
221
+ cursor.execute("DELETE FROM graph_nodes WHERE label IN ('Email', 'User')")
222
+ conn.commit()
223
+ conn.close()
224
+ else:
225
+ with self.driver.session() as session:
226
+ session.run("MATCH (e:Email) DETACH DELETE e")
227
+ session.run("MATCH (u:User) DETACH DELETE u")
228
+ else:
229
+ repo_prefix = f"Document:{r_name}:"
230
+ repo_node_id = f"Repository:{r_name}"
231
+ if self.is_fallback:
232
+ from storage.db import get_connection
233
+ conn = get_connection()
234
+ cursor = conn.cursor()
235
+ cursor.execute(
236
+ "DELETE FROM graph_relationships WHERE source_id = ? OR target_id = ? OR source_id LIKE ? OR target_id LIKE ?",
237
+ (repo_node_id, repo_node_id, f"{repo_prefix}%", f"{repo_prefix}%")
238
+ )
239
+ cursor.execute(
240
+ "DELETE FROM graph_nodes WHERE id = ? OR id LIKE ?",
241
+ (repo_node_id, f"{repo_prefix}%")
242
+ )
243
+ conn.commit()
244
+ conn.close()
245
+ else:
246
+ with self.driver.session() as session:
247
+ session.run("MATCH (r:Repository {id: $repo_id}) DETACH DELETE r", repo_id=repo_node_id)
248
+ session.run("MATCH (d:Document) WHERE d.id STARTS WITH $prefix DETACH DELETE d", prefix=repo_prefix)
249
+
250
+ node_count = 0
251
+ rel_count = 0
252
+
253
+ # Determine repos to sync
254
+ if repo_names is None:
255
+ repos_to_sync = get_all_repositories()
256
+ else:
257
+ all_repos = get_all_repositories()
258
+ repos_to_sync = [r for r in all_repos if r["repo_name"] in repo_names]
259
+
260
+ # 1. Repositories and detected technologies
261
+ for repo in repos_to_sync:
262
+ repo_name = repo["repo_name"]
263
+ repo_node_id = f"Repository:{repo_name}"
264
+ self.insert_node(repo_node_id, "Repository", repo_name)
265
+ node_count += 1
266
+
267
+ # Detect technology
268
+ techs = detect_tech_for_repo(repo_name)
269
+ for tech in techs:
270
+ tech_node_id = f"Technology:{tech}"
271
+ self.insert_node(tech_node_id, "Technology", tech)
272
+ node_count += 1
273
+
274
+ # Relationship USES
275
+ rel_id = f"{repo_name}-USES-{tech}"
276
+ self.insert_relationship(rel_id, repo_node_id, "Repository", tech_node_id, "Technology", "USES")
277
+ rel_count += 1
278
+
279
+ # 2. Documents inside repositories
280
+ if repo_names is None:
281
+ docs_to_sync = get_all_documents()
282
+ else:
283
+ all_docs = get_all_documents()
284
+ docs_to_sync = [d for d in all_docs if d["repo_name"] in repo_names]
285
+
286
+ for doc in docs_to_sync:
287
+ repo_name = doc["repo_name"]
288
+ file_name = doc["file_name"]
289
+
290
+ doc_node_id = f"Document:{repo_name}:{file_name}"
291
+ self.insert_node(doc_node_id, "Document", file_name)
292
+ node_count += 1
293
+
294
+ repo_node_id = f"Repository:{repo_name}"
295
+ # Relationship CONTAINS
296
+ rel_id = f"{repo_name}-CONTAINS-{file_name}"
297
+ self.insert_relationship(rel_id, repo_node_id, "Repository", doc_node_id, "Document", "CONTAINS")
298
+ rel_count += 1
299
+
300
+ # 3. Emails and senders
301
+ if repo_names is None or "__emails__" in repo_names:
302
+ emails = get_all_emails()
303
+ for email in emails:
304
+ subject = email["subject"] or "No Subject"
305
+ sender = email["sender"] or "Unknown Sender"
306
+ message_id = email["message_id"] or ""
307
+
308
+ email_node_id = f"Email:{message_id}"
309
+ self.insert_node(email_node_id, "Email", subject)
310
+ node_count += 1
311
+
312
+ user_node_id = f"User:{sender}"
313
+ self.insert_node(user_node_id, "User", sender)
314
+ node_count += 1
315
+
316
+ # Relationship SENT_BY
317
+ rel_id = f"{message_id}-SENT_BY-{sender}"
318
+ self.insert_relationship(rel_id, email_node_id, "Email", user_node_id, "User", "SENT_BY")
319
+ rel_count += 1
320
+
321
+ duration = time.perf_counter() - start_time
322
+ target_db = "SQLite Fallback" if self.is_fallback else "Neo4j"
323
+ logger.info(f"Graph sync complete. Synced to {target_db}. Nodes: {node_count}, Relationships: {rel_count} in {duration:.2f}s")
324
+ print(f"Graph Sync Complete. Target: {target_db}. Nodes: {node_count}, Relationships: {rel_count}. Duration: {duration:.2f}s")
storage/schema.sql ADDED
@@ -0,0 +1,57 @@
1
+ CREATE TABLE IF NOT EXISTS emails (
2
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3
+ message_id TEXT UNIQUE,
4
+ subject TEXT,
5
+ sender TEXT,
6
+ snippet TEXT,
7
+ received_at TEXT
8
+ );
9
+
10
+
11
+ CREATE TABLE IF NOT EXISTS repositories (
12
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
13
+ repo_name TEXT UNIQUE,
14
+ description TEXT,
15
+ language TEXT,
16
+ visibility TEXT,
17
+ stars INTEGER,
18
+ forks INTEGER,
19
+ open_issues INTEGER,
20
+ default_branch TEXT,
21
+ updated_at TEXT,
22
+ url TEXT
23
+ );
24
+
25
+ CREATE TABLE IF NOT EXISTS repository_documents (
26
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
27
+ repo_name TEXT,
28
+ file_name TEXT,
29
+ content TEXT,
30
+ source TEXT,
31
+ synced_at TEXT
32
+ );
33
+
34
+ CREATE TABLE IF NOT EXISTS document_chunks (
35
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
36
+ repository_name TEXT,
37
+ document_name TEXT,
38
+ source_type TEXT,
39
+ chunk_text TEXT,
40
+ chunk_index INTEGER,
41
+ created_at TEXT
42
+ );
43
+
44
+ CREATE TABLE IF NOT EXISTS graph_nodes (
45
+ id TEXT PRIMARY KEY,
46
+ label TEXT,
47
+ name TEXT
48
+ );
49
+
50
+ CREATE TABLE IF NOT EXISTS graph_relationships (
51
+ id TEXT PRIMARY KEY,
52
+ source_id TEXT,
53
+ target_id TEXT,
54
+ type TEXT,
55
+ FOREIGN KEY(source_id) REFERENCES graph_nodes(id),
56
+ FOREIGN KEY(target_id) REFERENCES graph_nodes(id)
57
+ );
@@ -0,0 +1,117 @@
1
+ import re
2
+ from storage.db import get_repository_details, get_all_repositories, get_connection
3
+
4
+ TECH_PATTERNS = {
5
+ "Anthropic": [r"\banthropic\b"],
6
+ "Composio": [r"\bcomposio\b"],
7
+ "Docker": [r"\bdocker\b"],
8
+ "Docker Compose": [r"\bdocker compose\b", r"\bdocker-compose\b"],
9
+ "Express": [r"\bexpress\b", r"\bexpress\.js\b", r"\bexpressjs\b"],
10
+ "FastAPI": [r"\bfastapi\b"],
11
+ "Firebase": [r"\bfirebase\b"],
12
+ "Flask": [r"\bflask\b"],
13
+ "Gemini": [r"\bgemini\b"],
14
+ "GraphQL": [r"\bgraphql\b"],
15
+ "Groq": [r"\bgroq\b"],
16
+ "JavaScript": [r"\bjavascript\b", r"\bjs\b"],
17
+ "Kafka": [r"\bkafka\b"],
18
+ "Kubernetes": [r"\bkubernetes\b", r"\bk8s\b"],
19
+ "LangChain": [r"\blangchain\b"],
20
+ "LlamaIndex": [r"\bllamaindex\b"],
21
+ "MongoDB": [r"\bmongodb\b", r"\bmongo\b"],
22
+ "Neo4j": [r"\bneo4j\b"],
23
+ "Next.js": [r"\bnext\.js\b", r"\bnextjs\b"],
24
+ "Node.js": [r"\bnode\.js\b", r"\bnodejs\b", r"\bnode\b"],
25
+ "OpenAI": [r"\bopenai\b"],
26
+ "Plotly": [r"\bplotly\b"],
27
+ "PostgreSQL": [r"\bpostgresql\b", r"\bpostgres\b"],
28
+ "Python": [r"\bpython\b"],
29
+ "Qdrant": [r"\bqdrant\b"],
30
+ "RabbitMQ": [r"\brabbitmq\b"],
31
+ "React": [r"\breact\b", r"\breact\.js\b", r"\breactjs\b"],
32
+ "React Native": [r"\breact native\b"],
33
+ "Redis": [r"\bredis\b"],
34
+ "REST API": [r"\brest[- ]api[s]?\b", r"\brestful\b"],
35
+ "Sentence Transformers": [r"\bsentence[- ]transformers\b", r"\bsentence_transformers\b"],
36
+ "SQLite": [r"\bsqlite\b", r"\bsqlite3\b"],
37
+ "Tailwind CSS": [r"\btailwind\b", r"\btailwindcss\b", r"\btailwind css\b"],
38
+ "TypeScript": [r"\btypescript\b", r"\bts\b"],
39
+ "Vite": [r"\bvite\b"]
40
+ }
41
+
42
+ # Compile patterns for faster regex scanning
43
+ COMPILED_PATTERNS = {
44
+ tech: [re.compile(p, re.IGNORECASE) for p in patterns]
45
+ for tech, patterns in TECH_PATTERNS.items()
46
+ }
47
+
48
+ def detect_tech_in_text(text: str) -> list:
49
+ if not text:
50
+ return []
51
+ detected = []
52
+ for tech, patterns in COMPILED_PATTERNS.items():
53
+ for pattern in patterns:
54
+ if pattern.search(text):
55
+ detected.append(tech)
56
+ break
57
+ return detected
58
+
59
+ def detect_tech_for_repo(repo_name: str) -> list:
60
+ details = get_repository_details(repo_name)
61
+ if not details:
62
+ return []
63
+
64
+ contents = []
65
+ if details.get("description"):
66
+ contents.append(details["description"])
67
+
68
+ conn = get_connection()
69
+ cursor = conn.cursor()
70
+ cursor.execute("SELECT content FROM repository_documents WHERE LOWER(repo_name) = LOWER(?)", (repo_name,))
71
+ for row in cursor.fetchall():
72
+ if row[0]:
73
+ contents.append(row[0])
74
+ conn.close()
75
+
76
+ combined_text = "\n".join(contents)
77
+ detected = detect_tech_in_text(combined_text)
78
+ return sorted(list(set(detected)))
79
+
80
+ def detect_all_tech() -> list:
81
+ repos = get_all_repositories()
82
+ all_tech = set()
83
+ for repo in repos:
84
+ tech_list = detect_tech_for_repo(repo["repo_name"])
85
+ all_tech.update(tech_list)
86
+ return sorted(list(all_tech))
87
+
88
+ def find_repos_by_tech(tech_name: str) -> list:
89
+ repos = get_all_repositories()
90
+ matching_repos = []
91
+ tech_name_lower = tech_name.lower()
92
+
93
+ for repo in repos:
94
+ repo_name = repo["repo_name"]
95
+ detected = detect_tech_for_repo(repo_name)
96
+
97
+ has_match = False
98
+ for t in detected:
99
+ if t.lower() == tech_name_lower:
100
+ has_match = True
101
+ break
102
+
103
+ if not has_match:
104
+ # Fallback direct substring/regex lookup in full text
105
+ conn = get_connection()
106
+ cursor = conn.cursor()
107
+ cursor.execute("SELECT content FROM repository_documents WHERE LOWER(repo_name) = LOWER(?)", (repo_name,))
108
+ combined_text = (repo.get("description") or "") + "\n" + "\n".join([row[0] for row in cursor.fetchall() if row[0]])
109
+ conn.close()
110
+
111
+ if re.search(r'\b' + re.escape(tech_name_lower) + r'\b', combined_text.lower()):
112
+ has_match = True
113
+
114
+ if has_match:
115
+ matching_repos.append(repo_name)
116
+
117
+ return sorted(matching_repos)