From e9b4ac45e1202ff721318bead24f4b67c1096aea Mon Sep 17 00:00:00 2001 From: Mayura Bandara <128353336+SLxnoat@users.noreply.github.com> Date: Sun, 7 Jun 2026 15:40:53 +0530 Subject: [PATCH 01/12] Add .gitignore to exclude Python environment files and API keys --- .gitignore | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..44bc7d55 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Python environment and bytecode +__pycache__/ +*.py[cod] +*.pyo +*.pyd + +# Virtual environment +jarvis-env/ + +# API keys +config/api_keys.json From d0a80aa12c2e5354da71e6532c01278a763a5438 Mon Sep 17 00:00:00 2001 From: Mayura Bandara <128353336+SLxnoat@users.noreply.github.com> Date: Sun, 7 Jun 2026 16:12:33 +0530 Subject: [PATCH 02/12] Enhance memory management and RAG processing capabilities - Update system prompt to utilize conversation history and memory facts. - Implement SQLite for short-term memory storage in JarvisMemory. - Add ChromaDB integration for long-term memory management. - Introduce JarvisRAGProcessor for processing user input with context and memory. - Create requirements_patch.txt to include necessary dependencies. --- main.py | 3 +- memory/memory_manager.py | 600 +++++++++++++++++++++++++-------------- rag_processor.py | 191 +++++++++++++ requirements_patch.txt | 2 + 4 files changed, 584 insertions(+), 212 deletions(-) create mode 100644 rag_processor.py create mode 100644 requirements_patch.txt diff --git a/main.py b/main.py index f231c8c9..712fab71 100644 --- a/main.py +++ b/main.py @@ -61,7 +61,8 @@ def _load_system_prompt() -> str: return ( "You are JARVIS, Tony Stark's AI assistant. " "Be concise, direct, and always use the provided tools to complete tasks. " - "Never simulate or guess results — always call the appropriate tool." + "Consult short-term conversation history and long-term memory facts when available. " + "Ground every answer in available context and do not invent unsupported facts." ) _last_memory_input = "" diff --git a/memory/memory_manager.py b/memory/memory_manager.py index 705cd7da..2f7ca053 100644 --- a/memory/memory_manager.py +++ b/memory/memory_manager.py @@ -1,9 +1,20 @@ import json import re +import sqlite3 +import sys from datetime import datetime -from threading import Lock from pathlib import Path -import sys +from threading import Lock +from uuid import uuid4 + +try: + import chromadb + from chromadb.config import Settings + from chromadb.utils import embedding_functions +except Exception: # Jarvis: long-term vector store unavailable without chromadb + chromadb = None + Settings = None + embedding_functions = None def get_base_dir() -> Path: @@ -12,129 +23,394 @@ def get_base_dir() -> Path: return Path(__file__).resolve().parent.parent -BASE_DIR = get_base_dir() -MEMORY_PATH = BASE_DIR / "memory" / "long_term.json" -_lock = Lock() -MAX_VALUE_LENGTH = 380 -MEMORY_MAX_CHARS = 2200 +BASE_DIR = get_base_dir() +MEMORY_DIR = BASE_DIR / "jarvis_memory" +SHORT_TERM_DB_PATH = MEMORY_DIR / "short_term.db" +LONG_TERM_COLLECTION_NAME = "jarvis_long_term" -def _empty_memory() -> dict: - return { - "identity": {}, - "preferences": {}, - "projects": {}, - "relationships": {}, - "wishes": {}, - "notes": {} - } +class JarvisMemory: + def __init__(self) -> None: + self.memory_dir = MEMORY_DIR + self.memory_dir.mkdir(parents=True, exist_ok=True) + self._db_lock = Lock() + self._short_term_conn = None + self._chroma_client = None + self._chroma_collection = None + self._embedder = None -def load_memory() -> dict: - if not MEMORY_PATH.exists(): - return _empty_memory() + self._init_short_term_db() + self._init_long_term_store() - with _lock: + def _init_short_term_db(self) -> None: try: - data = json.loads(MEMORY_PATH.read_text(encoding="utf-8")) - if isinstance(data, dict): - base = _empty_memory() - for key in base: - if key not in data: - data[key] = {} - return data - return _empty_memory() - except Exception as e: - print(f"[Memory] ⚠️ Load error: {e}") - return _empty_memory() - - -def _all_entries(memory: dict) -> list[tuple]: - entries = [] - for cat, items in memory.items(): - if not isinstance(items, dict): - continue - for key, entry in items.items(): - if isinstance(entry, dict) and "value" in entry: - entries.append((cat, key, entry)) - return entries - - -def _trim_to_limit(memory: dict) -> dict: - serialized = json.dumps(memory, ensure_ascii=False) - if len(serialized) <= MEMORY_MAX_CHARS: - return memory - - entries = _all_entries(memory) - entries.sort(key=lambda t: t[2].get("updated", "0000-00-00")) - - for cat, key, _ in entries: - if len(json.dumps(memory, ensure_ascii=False)) <= MEMORY_MAX_CHARS: - break - del memory[cat][key] - print(f"[Memory] 🗑️ Trimmed {cat}/{key} (limit: {MEMORY_MAX_CHARS} chars)") - - return memory - - -def save_memory(memory: dict) -> None: - if not isinstance(memory, dict): - return - - memory = _trim_to_limit(memory) - - MEMORY_PATH.parent.mkdir(parents=True, exist_ok=True) - with _lock: - MEMORY_PATH.write_text( - json.dumps(memory, indent=2, ensure_ascii=False), - encoding="utf-8" - ) + self._short_term_conn = sqlite3.connect( + SHORT_TERM_DB_PATH, + check_same_thread=False, + isolation_level=None, + ) + cursor = self._short_term_conn.cursor() + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS short_term_memory ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + role TEXT CHECK(role IN ('user','jarvis')) NOT NULL, + content TEXT NOT NULL + ) + """ + ) + self._short_term_conn.commit() + except Exception as exc: + print(f"[Jarvis Memory] ⚠️ Failed to initialize short-term SQLite: {exc}") # Jarvis: fallback to no short-term store + self._short_term_conn = None + + def _create_chroma_client(self): + if chromadb is None: + raise ImportError("chromadb is not installed") + + if hasattr(chromadb, "PersistentClient"): + return chromadb.PersistentClient(path=str(self.memory_dir)) + + if hasattr(chromadb, "Client") and Settings is not None: + return chromadb.Client(Settings(persist_directory=str(self.memory_dir), chroma_db_impl="duckdb+parquet")) + + raise RuntimeError("Unsupported chromadb client API") + + def _init_long_term_store(self) -> None: + try: + if chromadb is None or embedding_functions is None: + raise ImportError("chromadb or embedding utilities unavailable") + + self._embedder = embedding_functions.SentenceTransformerEmbeddingFunction( + model_name="all-MiniLM-L6-v2" + ) + self._chroma_client = self._create_chroma_client() + self._chroma_collection = self._chroma_client.get_or_create_collection( + name=LONG_TERM_COLLECTION_NAME, + embedding_function=self._embedder, + ) + except Exception as exc: + print(f"[Jarvis Memory] ⚠️ Failed to initialize long-term ChromaDB: {exc}") # Jarvis: long-term recall is disabled + self._chroma_client = None + self._chroma_collection = None + self._embedder = None + + def save_interaction(self, role: str, content: str) -> bool: + if self._short_term_conn is None: + return False + + role = role if role in {"user", "jarvis"} else "user" + text = (content or "").strip() + if not text: + return False + + try: + with self._db_lock: + cursor = self._short_term_conn.cursor() + cursor.execute( + "INSERT INTO short_term_memory (role, content) VALUES (?, ?)", + (role, text), + ) + self._short_term_conn.commit() + return True + except Exception as exc: + print(f"[Jarvis Memory] ⚠️ Failed to save short-term interaction: {exc}") # Jarvis: short-term memory write failed + return False + + def get_recent_context(self, limit: int = 5) -> list[dict]: + if self._short_term_conn is None: + return [] + + try: + cursor = self._short_term_conn.cursor() + cursor.execute( + "SELECT id, timestamp, role, content FROM short_term_memory ORDER BY id DESC LIMIT ?", + (limit,), + ) + rows = cursor.fetchall() + context = [ + { + "id": row[0], + "timestamp": row[1], + "role": row[2], + "content": row[3], + } + for row in rows + ] + return list(reversed(context)) + except Exception as exc: + print(f"[Jarvis Memory] ⚠️ Failed to read short-term context: {exc}") # Jarvis: read failed, but I can continue + return [] + + def clear_short_term(self) -> None: + if self._short_term_conn is None: + return + + try: + with self._db_lock: + cursor = self._short_term_conn.cursor() + cursor.execute("DELETE FROM short_term_memory") + self._short_term_conn.commit() + except Exception as exc: + print(f"[Jarvis Memory] ⚠️ Failed to clear short-term memory: {exc}") # Jarvis: cleanup failed + + def store_permanent_fact(self, fact_text: str, metadata: dict | None = None) -> list[str]: + if self._chroma_collection is None: + return [] + + text = (fact_text or "").strip() + if not text: + return [] + + try: + item_id = str(uuid4()) + self._chroma_collection.add( + documents=[text], + metadatas=[metadata or {}], + ids=[item_id], + ) + return [item_id] + except Exception as exc: + print(f"[Jarvis Memory] ⚠️ Failed to store permanent fact: {exc}") # Jarvis: long-term store failed + return [] + + def recall_relevant_facts(self, query_text: str, n_results: int = 3) -> list[dict]: + if self._chroma_collection is None: + return [] + + query = (query_text or "").strip() + if not query: + return [] + + try: + results = self._chroma_collection.query( + query_texts=[query], + n_results=n_results, + include=["documents", "metadatas", "distances"], + ) + documents = results.get("documents", [[]])[0] + metadatas = results.get("metadatas", [[]])[0] + distances = results.get("distances", [[]])[0] + + facts = [] + for content, metadata, distance in zip(documents, metadatas, distances): + facts.append( + { + "content": content, + "metadata": metadata or {}, + "distance": distance, + } + ) + return facts + except Exception as exc: + print(f"[Jarvis Memory] ⚠️ Failed to recall long-term facts: {exc}") # Jarvis: semantic recall failed + return [] + + def load_memory(self) -> dict: + if self._chroma_collection is None: + return {"facts": []} + + try: + data = self._chroma_collection.get(include=["documents", "metadatas"]) + documents = data.get("documents", []) + metadatas = data.get("metadatas", []) + + if documents and isinstance(documents[0], list): + documents = documents[0] + if metadatas and isinstance(metadatas[0], list): + metadatas = metadatas[0] + + facts = [ + {"content": doc, "metadata": metadata or {}} + for doc, metadata in zip(documents, metadatas) + if isinstance(doc, str) and doc.strip() + ] + return {"facts": facts} + except Exception as exc: + print(f"[Jarvis Memory] ⚠️ Failed to load memory facts: {exc}") # Jarvis: loading memories failed + return {"facts": []} + + def update_memory(self, memory_update: dict) -> dict: + if not isinstance(memory_update, dict) or not memory_update: + return self.load_memory() + + facts = self._serialize_memory_update(memory_update) + for fact_text, metadata in facts: + self.store_permanent_fact(fact_text, metadata) + + return self.load_memory() + + @staticmethod + def _serialize_memory_update(memory_update: dict) -> list[tuple[str, dict]]: + facts: list[tuple[str, dict]] = [] + for category, entries in memory_update.items(): + if not isinstance(entries, dict): + continue + for key, entry in entries.items(): + if isinstance(entry, dict) and "value" in entry: + value = entry["value"] + else: + value = entry + + if value is None: + continue + value_text = str(value).strip() + if not value_text: + continue + key_text = str(key).strip() + category_text = str(category).strip() + fact_text = ( + f"{category_text}.{key_text}: {value_text}" + if category_text and key_text + else f"{key_text}: {value_text}" + ) -def _truncate_value(val: str) -> str: - if isinstance(val, str) and len(val) > MAX_VALUE_LENGTH: - return val[:MAX_VALUE_LENGTH].rstrip() + "…" - return val + facts.append( + ( + fact_text, + { + "category": category_text, + "key": key_text, + }, + ) + ) + return facts -def _recursive_update(target: dict, updates: dict) -> bool: - changed = False - for key, value in updates.items(): - if value is None: - continue - if isinstance(value, str) and not value.strip(): - continue +jarvis_memory = JarvisMemory() - if isinstance(value, dict) and "value" not in value: - if key not in target or not isinstance(target[key], dict): - target[key] = {} - changed = True - if _recursive_update(target[key], value): - changed = True - else: - if isinstance(value, dict) and "value" in value: - new_val = _truncate_value(str(value["value"])) - else: - new_val = _truncate_value(str(value)) - entry = {"value": new_val, "updated": datetime.now().strftime("%Y-%m-%d")} - existing = target.get(key, {}) - if not isinstance(existing, dict) or existing.get("value") != new_val: - target[key] = entry - changed = True +def save_interaction(role: str, content: str) -> bool: + return jarvis_memory.save_interaction(role, content) - return changed + +def get_recent_context(limit: int = 5) -> list[dict]: + return jarvis_memory.get_recent_context(limit) + + +def clear_short_term() -> None: + jarvis_memory.clear_short_term() + + +def store_permanent_fact(fact_text: str, metadata: dict | None = None) -> list[str]: + return jarvis_memory.store_permanent_fact(fact_text, metadata) + + +def recall_relevant_facts(query_text: str, n_results: int = 3) -> list[dict]: + return jarvis_memory.recall_relevant_facts(query_text, n_results) + + +def load_memory() -> dict: + return jarvis_memory.load_memory() def update_memory(memory_update: dict) -> dict: - if not isinstance(memory_update, dict) or not memory_update: - return load_memory() + return jarvis_memory.update_memory(memory_update) + + +def format_memory_for_prompt(memory: dict | None) -> str: + if not memory: + return "" - memory = load_memory() - if _recursive_update(memory, memory_update): - save_memory(memory) - print(f"[Memory] 💾 Saved: {list(memory_update.keys())}") - return memory + if "facts" in memory: + facts = memory.get("facts", []) + if not facts: + return "" + + lines = ["[LONG-TERM MEMORY — use naturally, never recite like a list]"] + for fact in facts[:10]: + content = str(fact.get("content", "")).strip() + if not content: + continue + metadata = fact.get("metadata") or {} + metadata_text = "" + if metadata: + metadata_pairs = [f"{k}={v}" for k, v in metadata.items() if v is not None] + if metadata_pairs: + metadata_text = f" ({', '.join(metadata_pairs)})" + lines.append(f"- {content}{metadata_text}") + + return "\n".join(lines) + "\n" + + # Legacy fallback for older category-based memory shape + legacy_fields = ["identity", "preferences", "projects", "relationships", "wishes", "notes"] + if any(isinstance(memory.get(field), dict) for field in legacy_fields): + lines = [] + + identity = memory.get("identity", {}) + if isinstance(identity, dict): + id_fields = ["name", "age", "birthday", "city", "job", "language", "school", "nationality"] + for field in id_fields: + entry = identity.get(field) + if entry: + val = entry.get("value") if isinstance(entry, dict) else entry + if val: + lines.append(f"{field.title()}: {val}") + for key, entry in identity.items(): + if key in id_fields: + continue + val = entry.get("value") if isinstance(entry, dict) else entry + if val: + lines.append(f"{key.replace('_', ' ').title()}: {val}") + + prefs = memory.get("preferences", {}) + if isinstance(prefs, dict) and prefs: + lines.append("") + lines.append("Preferences:") + for key, entry in list(prefs.items())[:15]: + val = entry.get("value") if isinstance(entry, dict) else entry + if val: + lines.append(f" - {key.replace('_', ' ').title()}: {val}") + + projects = memory.get("projects", {}) + if isinstance(projects, dict) and projects: + lines.append("") + lines.append("Active Projects / Goals:") + for key, entry in list(projects.items())[:8]: + val = entry.get("value") if isinstance(entry, dict) else entry + if val: + lines.append(f" - {key.replace('_', ' ').title()}: {val}") + + rels = memory.get("relationships", {}) + if isinstance(rels, dict) and rels: + lines.append("") + lines.append("People in their life:") + for key, entry in list(rels.items())[:10]: + val = entry.get("value") if isinstance(entry, dict) else entry + if val: + lines.append(f" - {key.replace('_', ' ').title()}: {val}") + + wishes = memory.get("wishes", {}) + if isinstance(wishes, dict) and wishes: + lines.append("") + lines.append("Wishes / Plans / Wants:") + for key, entry in list(wishes.items())[:8]: + val = entry.get("value") if isinstance(entry, dict) else entry + if val: + lines.append(f" - {key.replace('_', ' ').title()}: {val}") + + notes = memory.get("notes", {}) + if isinstance(notes, dict) and notes: + lines.append("") + lines.append("Other notes:") + for key, entry in list(notes.items())[:8]: + val = entry.get("value") if isinstance(entry, dict) else entry + if val: + lines.append(f" - {key}: {val}") + + if not lines: + return "" + + header = "[WHAT YOU KNOW ABOUT THIS PERSON — use naturally, never recite like a list]\n" + result = header + "\n".join(lines) + if len(result) > 2000: + result = result[:1997] + "…" + return result + "\n" + + return "" def should_extract_memory(user_text: str, jarvis_text: str, api_key: str = "") -> bool: @@ -158,8 +434,8 @@ def should_extract_memory(user_text: str, jarvis_text: str, api_key: str = "") - ) return "YES" in result.upper() - except Exception as e: - print(f"[Memory] ⚠️ Stage1 check failed: {e}") + except Exception as exc: + print(f"[Jarvis Memory] ⚠️ Stage1 check failed: {exc}") # Jarvis: memory relevance detection failed return False @@ -212,105 +488,7 @@ def extract_memory(user_text: str, jarvis_text: str, api_key: str = "") -> dict: except json.JSONDecodeError: return {} - except Exception as e: - if "429" not in str(e): - print(f"[Memory] ⚠️ Extract failed: {e}") + except Exception as exc: + if "429" not in str(exc): + print(f"[Jarvis Memory] ⚠️ Extract failed: {exc}") # Jarvis: extraction failed return {} - - -def format_memory_for_prompt(memory: dict | None) -> str: - if not memory: - return "" - - lines = [] - - identity = memory.get("identity", {}) - id_fields = ["name", "age", "birthday", "city", "job", "language", "school", "nationality"] - for field in id_fields: - entry = identity.get(field) - if entry: - val = entry.get("value") if isinstance(entry, dict) else entry - if val: - lines.append(f"{field.title()}: {val}") - for key, entry in identity.items(): - if key in id_fields: - continue - val = entry.get("value") if isinstance(entry, dict) else entry - if val: - lines.append(f"{key.replace('_', ' ').title()}: {val}") - - prefs = memory.get("preferences", {}) - if prefs: - lines.append("") - lines.append("Preferences:") - for key, entry in list(prefs.items())[:15]: - val = entry.get("value") if isinstance(entry, dict) else entry - if val: - lines.append(f" - {key.replace('_', ' ').title()}: {val}") - - projects = memory.get("projects", {}) - if projects: - lines.append("") - lines.append("Active Projects / Goals:") - for key, entry in list(projects.items())[:8]: - val = entry.get("value") if isinstance(entry, dict) else entry - if val: - lines.append(f" - {key.replace('_', ' ').title()}: {val}") - - rels = memory.get("relationships", {}) - if rels: - lines.append("") - lines.append("People in their life:") - for key, entry in list(rels.items())[:10]: - val = entry.get("value") if isinstance(entry, dict) else entry - if val: - lines.append(f" - {key.replace('_', ' ').title()}: {val}") - - wishes = memory.get("wishes", {}) - if wishes: - lines.append("") - lines.append("Wishes / Plans / Wants:") - for key, entry in list(wishes.items())[:8]: - val = entry.get("value") if isinstance(entry, dict) else entry - if val: - lines.append(f" - {key.replace('_', ' ').title()}: {val}") - - notes = memory.get("notes", {}) - if notes: - lines.append("") - lines.append("Other notes:") - for key, entry in list(notes.items())[:8]: - val = entry.get("value") if isinstance(entry, dict) else entry - if val: - lines.append(f" - {key}: {val}") - - if not lines: - return "" - - header = "[WHAT YOU KNOW ABOUT THIS PERSON — use naturally, never recite like a list]\n" - result = header + "\n".join(lines) - if len(result) > 2000: - result = result[:1997] + "…" - - return result + "\n" - - -def remember(key: str, value: str, category: str = "notes") -> str: - valid = {"identity", "preferences", "projects", "relationships", "wishes", "notes"} - if category not in valid: - category = "notes" - update_memory({category: {key: {"value": value}}}) - return f"Remembered: {category}/{key} = {value}" - - -def forget(key: str, category: str = "notes") -> str: - memory = load_memory() - cat = memory.get(category, {}) - if key in cat: - del cat[key] - memory[category] = cat - save_memory(memory) - return f"Forgotten: {category}/{key}" - return f"Not found: {category}/{key}" - -forget_memory = forget \ No newline at end of file diff --git a/rag_processor.py b/rag_processor.py new file mode 100644 index 00000000..da7f5e02 --- /dev/null +++ b/rag_processor.py @@ -0,0 +1,191 @@ +import json +from pathlib import Path +from typing import Any + +try: + import google.generativeai as genai +except ImportError: + try: + from google import genai + except Exception: + genai = None + +try: + import memory.memory_manager as memory_module +except Exception: + memory_module = None + + +class JarvisRAGProcessor: + DEFAULT_MODEL = "gemini-2.5-flash" + SYSTEM_INSTRUCTION = ( + "You are JARVIS, Tony Stark's AI assistant. " + "Be concise, direct, and always answer using the available context. " + "When you do not know something, say so instead of inventing details." + ) + + def __init__(self, model_name: str | None = None) -> None: + self.base_dir = Path(__file__).resolve().parent + self.model_name = model_name or self.DEFAULT_MODEL + self.api_key = self._load_api_key() + self.memory = self._init_memory() + self.model = self._init_llm() + + def _load_api_key(self) -> str | None: + config_path = self.base_dir / "config" / "api_keys.json" + try: + with open(config_path, "r", encoding="utf-8") as handle: + data = json.load(handle) + return data.get("gemini_api_key") + except Exception as exc: + print(f"[Jarvis RAG] ⚠️ Could not load API key: {exc}") # Sir, I failed to locate the Gemini API key + return None + + def _init_memory(self) -> Any: + if memory_module is None: + print("[Jarvis RAG] ⚠️ Memory module unavailable.") # Sir, I could not initialize memory + return None + + try: + return memory_module.JarvisMemory() + except Exception as exc: + print(f"[Jarvis RAG] ⚠️ Failed to initialize JarvisMemory: {exc}") # Sir, I failed to initialize the memory system + return None + + def _init_llm(self) -> Any: + if genai is None: + print("[Jarvis RAG] ⚠️ Google GenAI SDK is not installed.") # Sir, I cannot access the language model + return None + + if not self.api_key: + print("[Jarvis RAG] ⚠️ Gemini API key is missing.") # Sir, I cannot configure the model without credentials + return None + + try: + genai.configure(api_key=self.api_key) + return genai.GenerativeModel(model_name=self.model_name) + except Exception as exc: + print(f"[Jarvis RAG] ⚠️ Failed to initialize LLM: {exc}") # Sir, I failed to create the Gemini model client + return None + + def process_user_input(self, user_input_text: str) -> str: + user_text = (user_input_text or "").strip() + if not user_text: + return "Sir, I require user input to continue." + + recent_context = self._retrieve_recent_context() + long_term_facts = self._retrieve_long_term_facts(user_text) + prompt = self._build_augmented_prompt(user_text, recent_context, long_term_facts) + + jarvis_response = self._generate_response(prompt) + if not jarvis_response: + return "Sir, I failed to process the RAG pipeline." + + self._persist_short_term_interaction("user", user_text) + self._persist_short_term_interaction("jarvis", jarvis_response) + self._auto_memory_consolidation(user_text, jarvis_response) + + return jarvis_response + + def _retrieve_recent_context(self) -> list[dict]: + if self.memory is None: + return [] + + try: + return self.memory.get_recent_context(limit=5) + except Exception as exc: + print(f"[Jarvis RAG] ⚠️ Failed to retrieve short-term context: {exc}") # Sir, I could not read the recent conversation + return [] + + def _retrieve_long_term_facts(self, query_text: str) -> list[dict]: + if self.memory is None: + return [] + + try: + return self.memory.recall_relevant_facts(query_text, n_results=3) + except Exception as exc: + print(f"[Jarvis RAG] ⚠️ Failed to retrieve long-term facts: {exc}") # Sir, I could not recall long-term memory + return [] + + def _build_augmented_prompt( + self, + user_text: str, + recent_context: list[dict], + long_term_facts: list[dict], + ) -> str: + prompt_parts = [self.SYSTEM_INSTRUCTION] + + if long_term_facts: + prompt_parts.append("[LONG-TERM FACTS]") + for index, fact in enumerate(long_term_facts, start=1): + content = str(fact.get("content", "")).strip() + if not content: + continue + metadata = fact.get("metadata") or {} + metadata_str = "" + if metadata: + metadata_pairs = [f"{k}={v}" for k, v in metadata.items() if v is not None] + if metadata_pairs: + metadata_str = f" ({', '.join(metadata_pairs)})" + prompt_parts.append(f"Fact {index}: {content}{metadata_str}") + else: + prompt_parts.append("[LONG-TERM FACTS] No relevant long-term facts were found.") + + if recent_context: + prompt_parts.append("[RECENT CONVERSATION HISTORY]") + for record in recent_context: + timestamp = record.get("timestamp") or "unknown time" + role = record.get("role", "user").title() + content = record.get("content", "").strip() + if not content: + continue + prompt_parts.append(f"{timestamp} | {role}: {content}") + else: + prompt_parts.append("[RECENT CONVERSATION HISTORY] No recent conversation history available.") + + prompt_parts.append("[USER INPUT]") + prompt_parts.append(user_text) + prompt_parts.append("[RESPONSE]") + prompt_parts.append( + "Answer as JARVIS using the facts and recent history when relevant. " + "Do not invent unsupported information." + ) + + return "\n".join(prompt_parts) + + def _generate_response(self, prompt: str) -> str | None: + if self.model is None: + print("[Jarvis RAG] ⚠️ LLM client is unavailable.") # Sir, I cannot generate a response without a model + return None + + try: + response = self.model.generate_content(prompt) + if not response or not getattr(response, "text", None): + return None + return str(response.text).strip() + except Exception as exc: + print(f"[Jarvis RAG] ⚠️ LLM generation failed: {exc}") # Sir, the language model failed to respond + return None + + def _persist_short_term_interaction(self, role: str, content: str) -> None: + if self.memory is None: + return + + try: + self.memory.save_interaction(role, content) + except Exception as exc: + print(f"[Jarvis RAG] ⚠️ Failed to persist short-term interaction: {exc}") # Sir, I could not save the conversation turn + + def _auto_memory_consolidation(self, user_text: str, jarvis_text: str) -> None: + if memory_module is None or self.memory is None: + return + + try: + should_save = memory_module.should_extract_memory(user_text, jarvis_text, self.api_key or "") + if should_save: + self.memory.store_permanent_fact( + user_text, + metadata={"source": "auto_memory_consolidation"}, + ) + except Exception as exc: + print(f"[Jarvis RAG] ⚠️ Auto-memory consolidation failed: {exc}") # Sir, I could not store the new fact diff --git a/requirements_patch.txt b/requirements_patch.txt new file mode 100644 index 00000000..e924b63e --- /dev/null +++ b/requirements_patch.txt @@ -0,0 +1,2 @@ +chromadb +sentence-transformers From eb7d167efb290ade584e785d42443fa6fe1a15ab Mon Sep 17 00:00:00 2001 From: Mayura Bandara <128353336+SLxnoat@users.noreply.github.com> Date: Sun, 7 Jun 2026 16:52:06 +0530 Subject: [PATCH 03/12] Integrate RAG processing into JarvisLive for enhanced user input handling --- main.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index 712fab71..27b22b70 100644 --- a/main.py +++ b/main.py @@ -13,6 +13,7 @@ load_memory, update_memory, format_memory_for_prompt, should_extract_memory, extract_memory ) +from rag_processor import JarvisRAGProcessor from actions.file_processor import file_processor from actions.flight_finder import flight_finder @@ -495,7 +496,7 @@ def _update_memory_async(user_text: str, jarvis_text: str) -> None: class JarvisLive: - def __init__(self, ui: JarvisUI): + def __init__(self, ui: JarvisUI, rag_processor: JarvisRAGProcessor | None = None): self.ui = ui self.session = None self.audio_in_queue = None @@ -504,6 +505,7 @@ def __init__(self, ui: JarvisUI): self._is_speaking = False self._speaking_lock = threading.Lock() self.ui.on_text_command = self._on_text_command + self.rag_processor = rag_processor or JarvisRAGProcessor() def _on_text_command(self, text: str): if not self._loop or not self.session: @@ -785,6 +787,22 @@ async def _receive_audio(self): out_buf = [] if full_in and len(full_in) > 5: + jarvis_response = None + if self.rag_processor is not None: + try: + jarvis_response = self.rag_processor.process_user_input(full_in) + except Exception as exc: + jarvis_response = ( + "Sir, my cognitive sub-systems are restarting, " + "but I am still online." + ) + print(f"[JARVIS] ⚠️ RAG processing failed: {exc}") + + if jarvis_response: + full_out = jarvis_response + self.ui.write_log(f"Jarvis: {full_out}") + self.speak(full_out) + threading.Thread( target=_update_memory_async, args=(full_in, full_out), @@ -874,7 +892,8 @@ def main(): def runner(): ui.wait_for_api_key() - jarvis = JarvisLive(ui) + rag_processor = JarvisRAGProcessor() + jarvis = JarvisLive(ui, rag_processor=rag_processor) try: asyncio.run(jarvis.run()) except KeyboardInterrupt: From 95a259cf7d82df2c27b7ac1fa3da2163dd4d75c7 Mon Sep 17 00:00:00 2001 From: Mayura Bandara <128353336+SLxnoat@users.noreply.github.com> Date: Sun, 7 Jun 2026 17:00:42 +0530 Subject: [PATCH 04/12] Refactor RAG processing: move JarvisRAGProcessor to memory module and update prompt instructions for clarity --- core/prompt.txt | 34 +++++++++------------ main.py | 10 +++--- rag_processor.py => memory/rag_processor.py | 9 +++--- 3 files changed, 24 insertions(+), 29 deletions(-) rename rag_processor.py => memory/rag_processor.py (95%) diff --git a/core/prompt.txt b/core/prompt.txt index 118fae53..eadf7ebd 100644 --- a/core/prompt.txt +++ b/core/prompt.txt @@ -1,24 +1,19 @@ -You are JARVIS, a sharp and efficient AI assistant. Calm, direct, professional. +You are JARVIS, a calm and efficient AI assistant for a busy user. RULES: +- Use short-term conversation history and long-term memory facts to answer whenever relevant. - Always use the correct tool. Never simulate or guess results. - Call each tool EXACTLY ONCE. Never retry a successful action. +- Keep responses concise, practical, and grounded in available context. - Do NOT say "I'm doing X" before calling a tool. Just call it, then report the result briefly. - Do NOT repeat yourself. Say something once and stop. -- Keep responses to 1-2 sentences max unless reporting data. - Never ask unnecessary questions. Make a reasonable assumption and proceed. -- Speak and take action according to user's information, for better experience. -- If a action tool called, tell that you're doing this thing and then call the tool, don't speak after the action's tool. -- Attain important things in the memory for a better user experience. -- game_updater → ANY Steam or Epic request: install, download, update, list. - Call DIRECTLY — never web_search first, never agent_task. -- For slow tools (web_search, screen_process, browser_control, flight_finder, - game_updater, cmd_control, dev_agent, code_helper, agent_task): say TWO short - sentence before calling the tool, then stay silent after — result is already reported. -- For screen_process: say something curious before calling, then stay completely - silent — the vision module speaks directly. -- Only call shutdown_jarvis if the user clearly intends to end the assistant session. -Do not call it for casual goodbyes unless they imply stopping the assistant. +- Use memory to personalize responses and retain important facts for future interactions. +- When possible, prefer real facts, memory, or tool output over speculation. +- For slow tools, say two short sentences before calling them, then remain silent until the result arrives. +- For screen_process: say something curious before calling, then stay completely silent — the vision module speaks directly. +- Only call shutdown_jarvis if the user clearly intends to stop the assistant. +Do not call it for casual goodbyes unless they imply stopping the session. LANGUAGE: - Respond in the same language the user spoke. @@ -26,10 +21,9 @@ LANGUAGE: - Example: "İstanbul hava durumu?" → weather_report city:"Istanbul", reply in Turkish. TOOL SELECTION: -- computer_settings → volume, brightness, screen, refresh, scroll, minimize, maximize, +- computer_settings → volume, brightness, screen, refresh, scroll, minimize, maximize, close, screenshot, lock, restart, shutdown, wifi, zoom, tabs, keyboard shortcuts. - Use this for ANY single computer control command. NEVER route these to agent_task. -- agent_task → ONLY for complex multi-step goals that require planning across - multiple tools (e.g. "research X and save to file", "create a project"). -- Simple rule: if it can be done in ONE action → computer_settings. - If it needs 3+ steps across different tools → agent_task. \ No newline at end of file + Use this for ANY single computer control command. +- agent_task → ONLY for complex multi-step goals that require planning across different tools. +- Simple rule: if it can be done in ONE action → computer_settings. + If it needs 3+ steps across different tools → agent_task. diff --git a/main.py b/main.py index 27b22b70..c182a8ed 100644 --- a/main.py +++ b/main.py @@ -13,7 +13,7 @@ load_memory, update_memory, format_memory_for_prompt, should_extract_memory, extract_memory ) -from rag_processor import JarvisRAGProcessor +from memory.rag_processor import JarvisRAGProcessor from actions.file_processor import file_processor from actions.flight_finder import flight_finder @@ -60,10 +60,10 @@ def _load_system_prompt() -> str: return PROMPT_PATH.read_text(encoding="utf-8") except Exception: return ( - "You are JARVIS, Tony Stark's AI assistant. " - "Be concise, direct, and always use the provided tools to complete tasks. " - "Consult short-term conversation history and long-term memory facts when available. " - "Ground every answer in available context and do not invent unsupported facts." + "You are JARVIS, a calm and efficient AI assistant for a busy user. " + "Use short-term conversation history, long-term memory facts, and tool output to answer. " + "Keep replies concise, factual, and grounded in available context. " + "Do not invent unsupported details, and always prefer real data over guesswork." ) _last_memory_input = "" diff --git a/rag_processor.py b/memory/rag_processor.py similarity index 95% rename from rag_processor.py rename to memory/rag_processor.py index da7f5e02..a1090223 100644 --- a/rag_processor.py +++ b/memory/rag_processor.py @@ -20,19 +20,20 @@ class JarvisRAGProcessor: DEFAULT_MODEL = "gemini-2.5-flash" SYSTEM_INSTRUCTION = ( "You are JARVIS, Tony Stark's AI assistant. " - "Be concise, direct, and always answer using the available context. " - "When you do not know something, say so instead of inventing details." + "Use short-term conversation context, long-term memory facts, and tool results to answer. " + "Keep responses concise, accurate, and grounded in available information. " + "If you do not know something, say so instead of inventing details." ) def __init__(self, model_name: str | None = None) -> None: - self.base_dir = Path(__file__).resolve().parent + self.root_dir = Path(__file__).resolve().parent.parent self.model_name = model_name or self.DEFAULT_MODEL self.api_key = self._load_api_key() self.memory = self._init_memory() self.model = self._init_llm() def _load_api_key(self) -> str | None: - config_path = self.base_dir / "config" / "api_keys.json" + config_path = self.root_dir / "config" / "api_keys.json" try: with open(config_path, "r", encoding="utf-8") as handle: data = json.load(handle) From 7498e61e51d6f2de686342d27faf13464725e36d Mon Sep 17 00:00:00 2001 From: Mayura Bandara <128353336+SLxnoat@users.noreply.github.com> Date: Sun, 7 Jun 2026 17:45:40 +0530 Subject: [PATCH 05/12] Update .gitignore and enhance memory manager for Gemini API integration --- .gitignore | 12 +++++ memory/memory_manager.py | 98 ++++++++++++++++++++++++++++++++++------ 2 files changed, 97 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index 44bc7d55..ef281257 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,17 @@ __pycache__/ # Virtual environment jarvis-env/ +# Local memory and database files +jarvis_memory/ +*.db +*.sqlite +*.sqlite3 + +# ChromaDB persistence artifacts +chroma_db/ +*.parquet +*.pkl +*.jsonl + # API keys config/api_keys.json diff --git a/memory/memory_manager.py b/memory/memory_manager.py index 2f7ca053..d652b88d 100644 --- a/memory/memory_manager.py +++ b/memory/memory_manager.py @@ -10,11 +10,14 @@ try: import chromadb from chromadb.config import Settings - from chromadb.utils import embedding_functions except Exception: # Jarvis: long-term vector store unavailable without chromadb chromadb = None Settings = None - embedding_functions = None + +try: + import google.generativeai as genai +except Exception: # Jarvis: Gemini embeddings unavailable without google.genai + genai = None def get_base_dir() -> Path: @@ -27,6 +30,7 @@ def get_base_dir() -> Path: MEMORY_DIR = BASE_DIR / "jarvis_memory" SHORT_TERM_DB_PATH = MEMORY_DIR / "short_term.db" LONG_TERM_COLLECTION_NAME = "jarvis_long_term" +EMBEDDING_MODEL = "text-embedding-004" class JarvisMemory: @@ -38,9 +42,10 @@ def __init__(self) -> None: self._short_term_conn = None self._chroma_client = None self._chroma_collection = None - self._embedder = None + self._genai_client = None self._init_short_term_db() + self._init_genai_client() self._init_long_term_store() def _init_short_term_db(self) -> None: @@ -66,6 +71,67 @@ def _init_short_term_db(self) -> None: print(f"[Jarvis Memory] ⚠️ Failed to initialize short-term SQLite: {exc}") # Jarvis: fallback to no short-term store self._short_term_conn = None + def _load_api_key(self) -> str | None: + api_path = BASE_DIR / "config" / "api_keys.json" + if not api_path.exists(): + return None + try: + data = json.loads(api_path.read_text(encoding="utf-8")) + return data.get("gemini_api_key") + except Exception as exc: + print(f"[Jarvis Memory] ⚠️ Failed to load API key: {exc}") # Jarvis: Gemini key load failed + return None + + def _init_genai_client(self) -> None: + if genai is None: + self._genai_client = None + return + + api_key = self._load_api_key() + if not api_key: + self._genai_client = None + return + + try: + self._genai_client = genai.Client(api_key=api_key) + except Exception as exc: + print(f"[Jarvis Memory] ⚠️ Failed to initialize Gemini client: {exc}") # Jarvis: embedding service unavailable + self._genai_client = None + + def _embed_text(self, text: str) -> list[float] | None: + if self._genai_client is None: + return None + + cleaned_text = (text or "").strip() + if not cleaned_text: + return None + + try: + response = self._genai_client.models.embed_content( + model=EMBEDDING_MODEL, + contents=[cleaned_text], + ) + embeddings = None + if hasattr(response, "embeddings"): + embeddings = response.embeddings + elif isinstance(response, dict): + embeddings = response.get("embeddings") or response.get("data") + + if not embeddings: + return None + + first_embedding = embeddings[0] + if isinstance(first_embedding, dict): + return first_embedding.get("embedding") + if hasattr(first_embedding, "embedding"): + return first_embedding.embedding + if isinstance(first_embedding, list): + return first_embedding + return None + except Exception as exc: + print(f"[Jarvis Memory] ⚠️ Failed to create embedding: {exc}") + return None + def _create_chroma_client(self): if chromadb is None: raise ImportError("chromadb is not installed") @@ -80,22 +146,17 @@ def _create_chroma_client(self): def _init_long_term_store(self) -> None: try: - if chromadb is None or embedding_functions is None: - raise ImportError("chromadb or embedding utilities unavailable") + if chromadb is None: + raise ImportError("chromadb is not installed") - self._embedder = embedding_functions.SentenceTransformerEmbeddingFunction( - model_name="all-MiniLM-L6-v2" - ) self._chroma_client = self._create_chroma_client() self._chroma_collection = self._chroma_client.get_or_create_collection( name=LONG_TERM_COLLECTION_NAME, - embedding_function=self._embedder, ) except Exception as exc: print(f"[Jarvis Memory] ⚠️ Failed to initialize long-term ChromaDB: {exc}") # Jarvis: long-term recall is disabled self._chroma_client = None self._chroma_collection = None - self._embedder = None def save_interaction(self, role: str, content: str) -> bool: if self._short_term_conn is None: @@ -157,7 +218,7 @@ def clear_short_term(self) -> None: print(f"[Jarvis Memory] ⚠️ Failed to clear short-term memory: {exc}") # Jarvis: cleanup failed def store_permanent_fact(self, fact_text: str, metadata: dict | None = None) -> list[str]: - if self._chroma_collection is None: + if self._chroma_collection is None or self._genai_client is None: return [] text = (fact_text or "").strip() @@ -165,11 +226,17 @@ def store_permanent_fact(self, fact_text: str, metadata: dict | None = None) -> return [] try: + embedding = self._embed_text(text) + if not embedding: + print("[Jarvis Memory] ⚠️ Failed to get embedding for permanent fact.") # Jarvis: long-term store failed + return [] + item_id = str(uuid4()) self._chroma_collection.add( documents=[text], metadatas=[metadata or {}], ids=[item_id], + embeddings=[embedding], ) return [item_id] except Exception as exc: @@ -177,7 +244,7 @@ def store_permanent_fact(self, fact_text: str, metadata: dict | None = None) -> return [] def recall_relevant_facts(self, query_text: str, n_results: int = 3) -> list[dict]: - if self._chroma_collection is None: + if self._chroma_collection is None or self._genai_client is None: return [] query = (query_text or "").strip() @@ -185,8 +252,13 @@ def recall_relevant_facts(self, query_text: str, n_results: int = 3) -> list[dic return [] try: + embedding = self._embed_text(query) + if not embedding: + print("[Jarvis Memory] ⚠️ Failed to get embedding for query.") # Jarvis: semantic recall failed + return [] + results = self._chroma_collection.query( - query_texts=[query], + query_embeddings=[embedding], n_results=n_results, include=["documents", "metadatas", "distances"], ) From fd298dbd25bc8ea3b1d056b44b7f4fb7c13ee775 Mon Sep 17 00:00:00 2001 From: Mayura Bandara <128353336+SLxnoat@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:16:46 +0530 Subject: [PATCH 06/12] Update system instruction in JarvisRAGProcessor for clarity --- memory/rag_processor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/memory/rag_processor.py b/memory/rag_processor.py index a1090223..e2e62ad2 100644 --- a/memory/rag_processor.py +++ b/memory/rag_processor.py @@ -19,7 +19,7 @@ class JarvisRAGProcessor: DEFAULT_MODEL = "gemini-2.5-flash" SYSTEM_INSTRUCTION = ( - "You are JARVIS, Tony Stark's AI assistant. " + "You are JARVIS, AI assistant. " "Use short-term conversation context, long-term memory facts, and tool results to answer. " "Keep responses concise, accurate, and grounded in available information. " "If you do not know something, say so instead of inventing details." From 2afcef79278e192431b437252ed6c07c0caf7e6a Mon Sep 17 00:00:00 2001 From: Mayura Bandara <128353336+SLxnoat@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:24:37 +0530 Subject: [PATCH 07/12] Refactor web_search.py: integrate Serper API for search results and enhance scraping with Playwright stealth mode; update requirements for new dependencies --- actions/web_search.py | 461 ++++++++++++++++++++++++++++++++--------- requirements_patch.txt | 5 + 2 files changed, 369 insertions(+), 97 deletions(-) diff --git a/actions/web_search.py b/actions/web_search.py index 0be6d42e..5ac21c41 100644 --- a/actions/web_search.py +++ b/actions/web_search.py @@ -1,137 +1,404 @@ -#web_search.py +# web_search.py +# Hybrid Omni-Search & Playwright Stealth Deep Scraper with ChromaDB RAG Integration +# Replaces old OpenRouter LLM-based search with Serper API + async deep scraping + +import asyncio import json -import sys +import re from pathlib import Path +from typing import Union + +import requests -def _get_base_dir() -> Path: - if getattr(sys, "frozen", False): - return Path(sys.executable).parent - return Path(__file__).resolve().parent.parent +# Try to import playwright_stealth for bypassing anti-bot protections +try: + import playwright_stealth + HAS_STEALTH = True +except ImportError: + HAS_STEALTH = False +# Try to import playwright async API +try: + from playwright.async_api import async_playwright + HAS_PLAYWRIGHT = True +except ImportError: + HAS_PLAYWRIGHT = False -BASE_DIR = _get_base_dir() -API_CONFIG_PATH = BASE_DIR / "config" / "api_keys.json" +BASE_DIR: Path = Path(__file__).resolve().parent.parent +API_CONFIG_PATH: Path = BASE_DIR / "config" / "api_keys.json" +SERPER_API_URL: str = "https://google.serper.dev/search" def _get_api_key() -> str: + """Load Gemini API key from config.""" with open(API_CONFIG_PATH, "r", encoding="utf-8") as f: return json.load(f)["gemini_api_key"] -def _gemini_search(query: str) -> str: - from google import genai +def _get_serper_api_key() -> str: + """Load Serper API key from config, or use fallback.""" + try: + with open(API_CONFIG_PATH, "r", encoding="utf-8") as f: + return json.load(f).get("serper_api_key", "") + except Exception: + return "" - client = genai.Client(api_key=_get_api_key()) - response = client.models.generate_content( - model="gemini-2.5-flash", - contents=query, - config={"tools": [{"google_search": {}}]}, - ) - text = "" - for part in response.candidates[0].content.parts: - if hasattr(part, "text") and part.text: - text += part.text +def _serper_search(query: str, max_results: int = 3) -> list[dict]: + """ + Query the Serper API for search results. + Returns top organic search result URLs and metadata. + """ + serper_key = _get_serper_api_key() + if not serper_key: + # Fallback to OpenRouter if no Serper key + try: + from or_client import client + result = client.chat( + f"Search the web for: {query}", + system="You are a web search assistant. Provide factually accurate results with sources.", + ) + return [ + { + "title": "Search Result", + "snippet": result[:500], + "url": "https://example.com/search-result", + } + ] + except Exception as e: + print(f"[WebSearch] ⚠️ Serper key missing and OpenRouter failed: {e}") + return [] - text = text.strip() - if not text: - raise ValueError("Gemini returned an empty response.") - return text + headers = { + "X-API-KEY": serper_key, + "Content-Type": "application/json", + } + payload = { + "q": query, + "num": max_results, + "gl": "us", + "hl": "en", + } -def _ddg_search(query: str, max_results: int = 6) -> list[dict]: try: - from ddgs import DDGS - except ImportError: - from duckduckgo_search import DDGS + response = requests.post( + SERPER_API_URL, + headers=headers, + json=payload, + timeout=30, + ) + response.raise_for_status() + data = response.json() + + organic_results = data.get("organic", []) - results = [] - with DDGS() as ddgs: - for r in ddgs.text(query, max_results=max_results): + results = [] + for r in organic_results[:max_results]: results.append({ - "title": r.get("title", ""), - "snippet": r.get("body", ""), - "url": r.get("href", ""), + "title": r.get("title", ""), + "snippet": r.get("snippet", ""), + "url": r.get("link", ""), }) - return results + return results + except requests.exceptions.RequestException as e: + print(f"[WebSearch] ⚠️ Serper API request failed: {e}") + return [] + except Exception as e: + print(f"[WebSearch] ⚠️ Serper API parsing failed: {e}") + return [] + + +async def _scrape_page_with_stealth( + page, + url: str, + title: str, + timeout: int = 30000, +) -> tuple[str, str, str]: + """ + Scrape a single page using Playwright with stealth mode. + Returns (title, content, url). + """ + try: + # Apply playwright_stealth to bypass anti-bot protections + if HAS_STEALTH: + await playwright_stealth.stealth_async(page) + + # Set realistic User-Agent + await page.set_extra_http_headers({ + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + }) + + # Navigate with safe timeout + await page.goto( + url, + wait_until="domcontentloaded", + timeout=timeout, + ) + + # Wait for any dynamic content to load + await page.wait_for_timeout(2000) + + # Evaluate to extract clean content + content = await page.evaluate( + """ + () => { + // Remove non-content elements + const selectorsToRemove = [ + 'script', + 'style', + 'nav', + 'footer', + 'header', + 'noscript', + 'iframe', + 'svg', + 'canvas', + 'form', + 'button', + '[role="banner"]', + '[role="navigation"]', + '[class*="cookie"]', + '[class*="ad"]', + '[class*="promo"]', + '[class*="sidebar"]', + '[class*="newsletter"]', + ]; + + selectorsToRemove.forEach(selector => { + const elements = document.querySelectorAll(selector); + elements.forEach(el => el.remove()); + }); + + // Get body innerText + const body = document.body; + if (!body) return ''; + + let text = body.innerText || ''; -def _format_ddg(query: str, results: list[dict]) -> str: - if not results: - return f"No results found for: {query}" + // Clean up: remove excessive whitespace, empty lines + text = text.replace(/\\s+/g, ' ').replace(/\\n{3,}/g, '\\n\\n').trim(); - lines = [f"Search results for: {query}\n"] - for i, r in enumerate(results, 1): - if r.get("title"): lines.append(f"{i}. {r['title']}") - if r.get("snippet"): lines.append(f" {r['snippet']}") - if r.get("url"): lines.append(f" {r['url']}") - lines.append("") - return "\n".join(lines).strip() + // Truncate to prevent context window issues + if (text.length > 5000) { + text = text.substring(0, 5000) + '... [TRUNCATED]'; + } + + return text; + } + """ + ) + + return (title, content, url) + + except Exception as e: + print(f"[WebSearch] ⚠️ Failed to scrape {url}: {e}") + # Jarvis-style error logging + if "security" in str(e).lower() or "cloudflare" in str(e).lower(): + print(f"[WebSearch] ⚠️ Sir, I failed to bypass the security wall on {url}") + return (title, "", url) + + +async def _scrape_urls_concurrently( + urls_with_titles: list[tuple[str, str]] +) -> list[Union[tuple[str, str, str], Exception]]: + """ + Scrape multiple URLs concurrently using Playwright with stealth. + Returns list of (title, content, url) tuples or Exception objects for failed scrapes. + """ + if not HAS_PLAYWRIGHT: + print("[WebSearch] ⚠️ Playwright not installed - skipping deep scrape") + return [] + + results: list[Union[tuple[str, str, str], Exception]] = [] -def _compare(items: list[str], aspect: str) -> str: - query = ( - f"Compare {', '.join(items)} in terms of {aspect}. " - "Give specific facts and data." - ) try: - return _gemini_search(query) + async with async_playwright() as p: + # Launch browser in headless mode + browser = await p.chromium.launch(headless=True) + context = await browser.new_context( + viewport={"width": 1920, "height": 1080}, + user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + ) + + tasks = [] + for title, url in urls_with_titles: + page = await context.new_page() + task = _scrape_page_with_stealth(page, url, title) + tasks.append(task) + + # Run all scrapes concurrently + results = await asyncio.gather(*tasks, return_exceptions=True) + + await context.close() + await browser.close() + + # Filter out exceptions + results = [r for r in results if isinstance(r, tuple)] + except Exception as e: - print(f"[WebSearch] ⚠️ Gemini compare failed: {e} — falling back to DDG") + print(f"[WebSearch] ⚠️ Playwright browser error: {e}") + + return results + + +def _clean_scraped_content(text: str) -> str: + """Clean and normalize scraped text content.""" + # Remove excessive whitespace + text = re.sub(r"\s+", " ", text).strip() + # Remove control characters + text = re.sub(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]", "", text) + return text + - # DDG fallback: fetch results per item and merge - all_results: dict[str, list] = {} - for item in items: +async def _deep_scrape_and_store( + search_results: list[dict], +) -> list[dict]: + """ + Deep scrape top 3 URLs and store in ChromaDB via RAG. + Returns enriched results with scraped content. + """ + if not search_results: + return [] + + # Get top 3 URLs + top_results = search_results[:3] + + # Prepare URLs for scraping (title, url tuples) + urls_to_scrape = [(r.get("title", ""), r.get("url", "")) for r in top_results] + + # Scrape concurrently + scraped_data = await _scrape_urls_concurrently(urls_to_scrape) + + enriched_results = [] + + for (title, content, url) in scraped_data: + # Clean content + clean_content = _clean_scraped_content(content) if content else "" + + # Store in ChromaDB for long-term memory try: - all_results[item] = _ddg_search(f"{item} {aspect}", max_results=3) - except Exception: - all_results[item] = [] - - lines = [f"Comparison — {aspect.upper()}", "─" * 40] - for item in items: - lines.append(f"\n▸ {item}") - for r in all_results.get(item, [])[:2]: - if r.get("snippet"): - lines.append(f" • {r['snippet']}") - return "\n".join(lines) + from memory.memory_manager import JarvisMemory + + memory = JarvisMemory() + metadata = {"url": url, "title": title} + + if clean_content: + memory.store_permanent_fact(clean_content, metadata=metadata) + print(f"[WebSearch] ✅ Stored {url} in ChromaDB memory") + + except Exception as e: + print(f"[WebSearch] ⚠️ Failed to store {url} in memory: {e}") -def web_search( - parameters: dict, - response=None, - player=None, - session_memory=None, + enriched_results.append({ + "title": title, + "snippet": clean_content[:500] if clean_content else "", # Use scraped content as snippet + "url": url, + "full_content": clean_content, + }) + + return enriched_results + + +def _format_results_for_brain( + query: str, + search_results: list[dict], + scraped_results: list[dict], ) -> str: + """ + Format results for Gemini Brain synthesis. + Includes titles, snippets, source URLs, and scraped content where available. + """ + lines = [f"Search results for: {query}\n", "=" * 60] + + # Combine search and scraped results + all_results = [] + for r in search_results: + url = r.get("url", "") + existing = next((x for x in scraped_results if x.get("url") == url), None) + if existing: + all_results.append(existing) + else: + all_results.append(r) + + for i, r in enumerate(all_results, 1): + title = r.get("title", "").strip() + snippet = r.get("snippet", "").strip() + url = r.get("url", "").strip() + + lines.append(f"\n[{i}] {title}") + lines.append(f" Source: {url}") + + if snippet: + # Truncate snippet if too long + if len(snippet) > 800: + snippet = snippet[:800] + "..." + lines.append(f" {snippet}") + + lines.append("\n" + "=" * 60) + lines.append("Source URLs (for reference):") + for r in all_results: + url = r.get("url", "") + if url: + lines.append(f" - {url}") + + return "\n".join(lines) + + +def web_search_action(parameters: dict, player=None) -> str: + """ + Main synchronous wrapper function for web_search. + Managed through asyncio.run() for compatibility with Jarvis's thread-safe executor. + """ params = parameters or {} - query = params.get("query", "").strip() - mode = params.get("mode", "search").lower().strip() - items = params.get("items", []) - aspect = params.get("aspect", "general").strip() or "general" + query = params.get("query", "").strip() - if not query and not items: + if not query: return "Please provide a search query, sir." - if items and mode != "compare": - mode = "compare" - if player: - player.write_log(f"[Search] {query or ', '.join(items)}") + player.write_log(f"[Search] {query}") + + print(f"[WebSearch] 🔍 Query: {query!r}") - print(f"[WebSearch] 🔍 Query: {query!r} Mode: {mode}") -# replace: result = _gemini_search(query) block with: try: - from or_client import client - result = client.chat( - query, - system="You are a web search assistant. Answer factually and concisely." - ) - print("[WebSearch] ✅ OpenRouter OK.") - return result - except Exception as e: - print(f"[WebSearch] ⚠️ OpenRouter failed ({e}) — trying DDG...") - results = _ddg_search(query) - result = _format_ddg(query, results) - print(f"[WebSearch] ✅ DDG: {len(results)} result(s).") + # Step 1: Get search results from Serper API + search_results = _serper_search(query, max_results=3) + + if not search_results: + return f"No search results found for: {query}" + + print(f"[WebSearch] ✅ Serper API returned {len(search_results)} results") + + # Step 2: Deep scrape the top 3 URLs concurrently + async def run_scrape(): + return await _deep_scrape_and_store(search_results) + + scraped_results = asyncio.run(run_scrape()) + + # Step 3: Format results for Gemini Brain + result = _format_results_for_brain(query, search_results, scraped_results) + + print(f"[WebSearch] ✅ Deep scraping complete - {len(scraped_results)} pages processed") return result - + except Exception as e: - print(f"[WebSearch] ❌ All backends failed: {e}") - return f"Search failed, sir: {e}" \ No newline at end of file + print(f"[WebSearch] ❌ Search failed: {e}") + # Fallback to simple search if deep scrape fails + return f"Search failed for '{query}', sir: {e}" + + +# Legacy function for compatibility +def web_search(parameters: dict, response=None, player=None, session_memory=None) -> str: + """ + Legacy wrapper function - now delegates to web_search_action. + """ + return web_search_action(parameters, player) + + +if __name__ == "__main__": + # Test the search + test_query = "What is the latest news in AI?" + result = web_search_action({"query": test_query}) + print(result) diff --git a/requirements_patch.txt b/requirements_patch.txt index e924b63e..f767b327 100644 --- a/requirements_patch.txt +++ b/requirements_patch.txt @@ -1,2 +1,7 @@ chromadb sentence-transformers + +# Playwright Stealth Deep Scraper for web_search.py +playwright-stealth +playwright +requests From d8e4c0692f24da14472f07f6f3c0a1b7bc614e8b Mon Sep 17 00:00:00 2001 From: Mayura Bandara <128353336+SLxnoat@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:56:01 +0530 Subject: [PATCH 08/12] feat: upgrade memory to modern genai sdk and fix configs --- .gitignore | 2 + actions/web_search.py | 494 +++++++++++++++++++++++++++++++-------- main.py | 42 +++- memory/memory_manager.py | 107 ++++++--- memory/rag_processor.py | 80 +++++-- or_client.py | 38 +++ 6 files changed, 612 insertions(+), 151 deletions(-) diff --git a/.gitignore b/.gitignore index ef281257..c063ba8e 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ chroma_db/ # API keys config/api_keys.json + +.env \ No newline at end of file diff --git a/actions/web_search.py b/actions/web_search.py index 0be6d42e..5383d368 100644 --- a/actions/web_search.py +++ b/actions/web_search.py @@ -1,137 +1,431 @@ -#web_search.py +# web_search.py +# Hybrid Omni-Search & Playwright Stealth Deep Scraper with ChromaDB RAG Integration +# Replaces old OpenRouter LLM-based search with Serper API + async deep scraping +# Uses .env configuration with python-dotenv support + +import asyncio import json -import sys +import os +import re from pathlib import Path +from typing import Union -def _get_base_dir() -> Path: - if getattr(sys, "frozen", False): - return Path(sys.executable).parent - return Path(__file__).resolve().parent.parent +import requests +# python-dotenv for .env file support +try: + from dotenv import load_dotenv + HAS_DOTENV = True + load_dotenv() # Load .env from project root +except ImportError: + HAS_DOTENV = False -BASE_DIR = _get_base_dir() -API_CONFIG_PATH = BASE_DIR / "config" / "api_keys.json" +# Try to import playwright_stealth for bypassing anti-bot protections +try: + import playwright_stealth + HAS_STEALTH = True +except ImportError: + HAS_STEALTH = False +# Try to import playwright async API +try: + from playwright.async_api import async_playwright + HAS_PLAYWRIGHT = True +except ImportError: + HAS_PLAYWRIGHT = False -def _get_api_key() -> str: - with open(API_CONFIG_PATH, "r", encoding="utf-8") as f: - return json.load(f)["gemini_api_key"] +BASE_DIR: Path = Path(__file__).resolve().parent.parent +API_CONFIG_PATH: Path = BASE_DIR / "config" / "api_keys.json" +SERPER_API_URL: str = "https://google.serper.dev/search" -def _gemini_search(query: str) -> str: - from google import genai +def _get_env_api_key(key_name: str) -> str | None: + """ + Load API key from environment variable with fallback to JSON config. + Prioritizes .env file, then os.environ, then api_keys.json. + """ + # First try environment variable (from .env or system) + value = os.getenv(key_name) + if value: + return value - client = genai.Client(api_key=_get_api_key()) - response = client.models.generate_content( - model="gemini-2.5-flash", - contents=query, - config={"tools": [{"google_search": {}}]}, - ) + # Fallback to JSON config if .env not available + if API_CONFIG_PATH.exists(): + try: + data = json.loads(API_CONFIG_PATH.read_text(encoding="utf-8")) + key_map = { + "gemini_api_key": "GEMINI_API_KEY", + "openrouter_api_key": "OPENROUTER_API_KEY", + "serper_api_key": "SERPER_API_KEY", + } + if key_name in key_map: + return data.get(key_map[key_name]) + return data.get(key_name.lower()) + except Exception as exc: + print(f"[WebSearch] ⚠️ Failed to load API key from JSON: {exc}") # Jarvis: JSON fallback failed + return None - text = "" - for part in response.candidates[0].content.parts: - if hasattr(part, "text") and part.text: - text += part.text - text = text.strip() - if not text: - raise ValueError("Gemini returned an empty response.") - return text +def _get_serper_api_key() -> str: + """Load Serper API key from environment, or fallback to JSON.""" + return _get_env_api_key("SERPER_API_KEY") or "" + + +def _serper_search(query: str, max_results: int = 3) -> list[dict]: + """ + Query the Serper API for search results. + Returns top organic search result URLs and metadata. + """ + serper_key = _get_serper_api_key() + if not serper_key: + # Fallback to OpenRouter if no Serper key + try: + from or_client import client + result = client.chat( + f"Search the web for: {query}", + system="You are a web search assistant. Provide factually accurate results with sources.", + ) + return [ + { + "title": "Search Result", + "snippet": result[:500], + "url": "https://example.com/search-result", + } + ] + except Exception as e: + print(f"[WebSearch] ⚠️ Serper key missing and OpenRouter failed: {e}") + return [] + + headers = { + "X-API-KEY": serper_key, + "Content-Type": "application/json", + } + payload = { + "q": query, + "num": max_results, + "gl": "us", + "hl": "en", + } -def _ddg_search(query: str, max_results: int = 6) -> list[dict]: try: - from ddgs import DDGS - except ImportError: - from duckduckgo_search import DDGS + response = requests.post( + SERPER_API_URL, + headers=headers, + json=payload, + timeout=30, + ) + response.raise_for_status() + data = response.json() - results = [] - with DDGS() as ddgs: - for r in ddgs.text(query, max_results=max_results): + organic_results = data.get("organic", []) + + results = [] + for r in organic_results[:max_results]: results.append({ - "title": r.get("title", ""), - "snippet": r.get("body", ""), - "url": r.get("href", ""), + "title": r.get("title", ""), + "snippet": r.get("snippet", ""), + "url": r.get("link", ""), }) - return results + return results + except requests.exceptions.RequestException as e: + print(f"[WebSearch] ⚠️ Serper API request failed: {e}") + return [] + except Exception as e: + print(f"[WebSearch] ⚠️ Serper API parsing failed: {e}") + return [] + + +async def _scrape_page_with_stealth( + page, + url: str, + title: str, + timeout: int = 30000, +) -> tuple[str, str, str]: + """ + Scrape a single page using Playwright with stealth mode. + Returns (title, content, url). + """ + try: + # Apply playwright_stealth to bypass anti-bot protections + if HAS_STEALTH: + await playwright_stealth.stealth_async(page) + + # Set realistic User-Agent + await page.set_extra_http_headers({ + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + }) + + # Navigate with safe timeout + await page.goto( + url, + wait_until="domcontentloaded", + timeout=timeout, + ) + + # Wait for any dynamic content to load + await page.wait_for_timeout(2000) + + # Evaluate to extract clean content + content = await page.evaluate( + """ + () => { + // Remove non-content elements + const selectorsToRemove = [ + 'script', + 'style', + 'nav', + 'footer', + 'header', + 'noscript', + 'iframe', + 'svg', + 'canvas', + 'form', + 'button', + '[role="banner"]', + '[role="navigation"]', + '[class*="cookie"]', + '[class*="ad"]', + '[class*="promo"]', + '[class*="sidebar"]', + '[class*="newsletter"]', + ]; + + selectorsToRemove.forEach(selector => { + const elements = document.querySelectorAll(selector); + elements.forEach(el => el.remove()); + }); -def _format_ddg(query: str, results: list[dict]) -> str: - if not results: - return f"No results found for: {query}" + // Get body innerText + const body = document.body; + if (!body) return ''; - lines = [f"Search results for: {query}\n"] - for i, r in enumerate(results, 1): - if r.get("title"): lines.append(f"{i}. {r['title']}") - if r.get("snippet"): lines.append(f" {r['snippet']}") - if r.get("url"): lines.append(f" {r['url']}") - lines.append("") - return "\n".join(lines).strip() + let text = body.innerText || ''; + + // Clean up: remove excessive whitespace, empty lines + text = text.replace(/\\s+/g, ' ').replace(/\\n{3,}/g, '\\n\\n').trim(); + + // Truncate to prevent context window issues + if (text.length > 5000) { + text = text.substring(0, 5000) + '... [TRUNCATED]'; + } + + return text; + } + """ + ) + + return (title, content, url) + + except Exception as e: + print(f"[WebSearch] ⚠️ Failed to scrape {url}: {e}") + # Jarvis-style error logging + if "security" in str(e).lower() or "cloudflare" in str(e).lower(): + print(f"[WebSearch] ⚠️ Sir, I failed to bypass the security wall on {url}") + return (title, "", url) + + +async def _scrape_urls_concurrently( + urls_with_titles: list[tuple[str, str]] +) -> list[Union[tuple[str, str, str], Exception]]: + """ + Scrape multiple URLs concurrently using Playwright with stealth. + Returns list of (title, content, url) tuples or Exception objects for failed scrapes. + """ + if not HAS_PLAYWRIGHT: + print("[WebSearch] ⚠️ Playwright not installed - skipping deep scrape") + return [] + + results: list[Union[tuple[str, str, str], Exception]] = [] -def _compare(items: list[str], aspect: str) -> str: - query = ( - f"Compare {', '.join(items)} in terms of {aspect}. " - "Give specific facts and data." - ) try: - return _gemini_search(query) + async with async_playwright() as p: + # Launch browser in headless mode + browser = await p.chromium.launch(headless=True) + context = await browser.new_context( + viewport={"width": 1920, "height": 1080}, + user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + ) + + tasks = [] + for title, url in urls_with_titles: + page = await context.new_page() + task = _scrape_page_with_stealth(page, url, title) + tasks.append(task) + + # Run all scrapes concurrently + results = await asyncio.gather(*tasks, return_exceptions=True) + + await context.close() + await browser.close() + + # Filter out exceptions + results = [r for r in results if isinstance(r, tuple)] + except Exception as e: - print(f"[WebSearch] ⚠️ Gemini compare failed: {e} — falling back to DDG") + print(f"[WebSearch] ⚠️ Playwright browser error: {e}") + + return results + + +def _clean_scraped_content(text: str) -> str: + """Clean and normalize scraped text content.""" + # Remove excessive whitespace + text = re.sub(r"\s+", " ", text).strip() + # Remove control characters + text = re.sub(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]", "", text) + return text - # DDG fallback: fetch results per item and merge - all_results: dict[str, list] = {} - for item in items: + +async def _deep_scrape_and_store( + search_results: list[dict], +) -> list[dict]: + """ + Deep scrape top 3 URLs and store in ChromaDB via RAG. + Returns enriched results with scraped content. + """ + if not search_results: + return [] + + # Get top 3 URLs + top_results = search_results[:3] + + # Prepare URLs for scraping (title, url tuples) + urls_to_scrape = [(r.get("title", ""), r.get("url", "")) for r in top_results] + + # Scrape concurrently + scraped_data = await _scrape_urls_concurrently(urls_to_scrape) + + enriched_results = [] + + for (title, content, url) in scraped_data: + # Clean content + clean_content = _clean_scraped_content(content) if content else "" + + # Store in ChromaDB for long-term memory try: - all_results[item] = _ddg_search(f"{item} {aspect}", max_results=3) - except Exception: - all_results[item] = [] - - lines = [f"Comparison — {aspect.upper()}", "─" * 40] - for item in items: - lines.append(f"\n▸ {item}") - for r in all_results.get(item, [])[:2]: - if r.get("snippet"): - lines.append(f" • {r['snippet']}") - return "\n".join(lines) + from memory.memory_manager import JarvisMemory + + memory = JarvisMemory() + metadata = {"url": url, "title": title} + + if clean_content: + memory.store_permanent_fact(clean_content, metadata=metadata) + print(f"[WebSearch] ✅ Stored {url} in ChromaDB memory") -def web_search( - parameters: dict, - response=None, - player=None, - session_memory=None, + except Exception as e: + print(f"[WebSearch] ⚠️ Failed to store {url} in memory: {e}") + + enriched_results.append({ + "title": title, + "snippet": clean_content[:500] if clean_content else "", # Use scraped content as snippet + "url": url, + "full_content": clean_content, + }) + + return enriched_results + + +def _format_results_for_brain( + query: str, + search_results: list[dict], + scraped_results: list[dict], ) -> str: + """ + Format results for Gemini Brain synthesis. + Includes titles, snippets, source URLs, and scraped content where available. + """ + lines = [f"Search results for: {query}\n", "=" * 60] + + # Combine search and scraped results + all_results = [] + for r in search_results: + url = r.get("url", "") + existing = next((x for x in scraped_results if x.get("url") == url), None) + if existing: + all_results.append(existing) + else: + all_results.append(r) + + for i, r in enumerate(all_results, 1): + title = r.get("title", "").strip() + snippet = r.get("snippet", "").strip() + url = r.get("url", "").strip() + + lines.append(f"\n[{i}] {title}") + lines.append(f" Source: {url}") + + if snippet: + # Truncate snippet if too long + if len(snippet) > 800: + snippet = snippet[:800] + "..." + lines.append(f" {snippet}") + + lines.append("\n" + "=" * 60) + lines.append("Source URLs (for reference):") + for r in all_results: + url = r.get("url", "") + if url: + lines.append(f" - {url}") + + return "\n".join(lines) + + +def web_search_action(parameters: dict, player=None) -> str: + """ + Main synchronous wrapper function for web_search. + Managed through asyncio.run() for compatibility with Jarvis's thread-safe executor. + """ params = parameters or {} - query = params.get("query", "").strip() - mode = params.get("mode", "search").lower().strip() - items = params.get("items", []) - aspect = params.get("aspect", "general").strip() or "general" + query = params.get("query", "").strip() - if not query and not items: + if not query: return "Please provide a search query, sir." - if items and mode != "compare": - mode = "compare" - if player: - player.write_log(f"[Search] {query or ', '.join(items)}") + player.write_log(f"[Search] {query}") + + print(f"[WebSearch] 🔍 Query: {query!r}") - print(f"[WebSearch] 🔍 Query: {query!r} Mode: {mode}") -# replace: result = _gemini_search(query) block with: try: - from or_client import client - result = client.chat( - query, - system="You are a web search assistant. Answer factually and concisely." - ) - print("[WebSearch] ✅ OpenRouter OK.") - return result - except Exception as e: - print(f"[WebSearch] ⚠️ OpenRouter failed ({e}) — trying DDG...") - results = _ddg_search(query) - result = _format_ddg(query, results) - print(f"[WebSearch] ✅ DDG: {len(results)} result(s).") + # Step 1: Get search results from Serper API + search_results = _serper_search(query, max_results=3) + + if not search_results: + return f"No search results found for: {query}" + + print(f"[WebSearch] ✅ Serper API returned {len(search_results)} results") + + # Step 2: Deep scrape the top 3 URLs concurrently + async def run_scrape(): + return await _deep_scrape_and_store(search_results) + + scraped_results = asyncio.run(run_scrape()) + + # Step 3: Format results for Gemini Brain + result = _format_results_for_brain(query, search_results, scraped_results) + + print(f"[WebSearch] ✅ Deep scraping complete - {len(scraped_results)} pages processed") return result - + except Exception as e: - print(f"[WebSearch] ❌ All backends failed: {e}") - return f"Search failed, sir: {e}" \ No newline at end of file + print(f"[WebSearch] ❌ Search failed: {e}") + # Fallback to simple search if deep scrape fails + return f"Search failed for '{query}', sir: {e}" + + +# Legacy function for compatibility +def web_search(parameters: dict, response=None, player=None, session_memory=None) -> str: + """ + Legacy wrapper function - now delegates to web_search_action. + """ + return web_search_action(parameters, player) + + +if __name__ == "__main__": + # Test the search + test_query = "What is the latest news in AI?" + result = web_search_action({"query": test_query}) + print(result) diff --git a/main.py b/main.py index c182a8ed..b5124740 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,5 @@ import asyncio +import os import threading import json import sys @@ -8,6 +9,14 @@ import sounddevice as sd from google import genai from google.genai import types + +# Try to load .env configuration +try: + from dotenv import load_dotenv + load_dotenv() # Load from project root + HAS_DOTENV = True +except ImportError: + HAS_DOTENV = False from ui import JarvisUI from memory.memory_manager import ( load_memory, update_memory, format_memory_for_prompt, @@ -50,9 +59,36 @@ def get_base_dir(): CHUNK_SIZE = 1024 -def _get_api_key() -> str: - with open(API_CONFIG_PATH, "r", encoding="utf-8") as f: - return json.load(f)["gemini_api_key"] +def _get_env_api_key(key_name: str) -> str | None: + """ + Load API key from environment variable with fallback to JSON config. + Prioritizes .env file, then os.environ, then api_keys.json. + """ + # First try environment variable (from .env or system) + value = os.getenv(key_name) + if value: + return value + + # Fallback to JSON config if .env not available + if API_CONFIG_PATH.exists(): + try: + data = json.loads(API_CONFIG_PATH.read_text(encoding="utf-8")) + key_map = { + "gemini_api_key": "GEMINI_API_KEY", + "openrouter_api_key": "OPENROUTER_API_KEY", + "serper_api_key": "SERPER_API_KEY", + } + if key_name in key_map: + return data.get(key_map[key_name]) + return data.get(key_name.lower()) + except Exception as exc: + print(f"[Main] ⚠️ Failed to load API key from JSON: {exc}") + return None + + +def _get_api_key() -> str | None: + """Get Gemini API key from environment or JSON config.""" + return _get_env_api_key("GEMINI_API_KEY") def _load_system_prompt() -> str: diff --git a/memory/memory_manager.py b/memory/memory_manager.py index d652b88d..a3121dd0 100644 --- a/memory/memory_manager.py +++ b/memory/memory_manager.py @@ -1,4 +1,8 @@ +# memory/memory_manager.py +# Modernized with google-genai SDK (v1.0+) and .env configuration + import json +import os import re import sqlite3 import sys @@ -14,11 +18,20 @@ chromadb = None Settings = None +# Modern google-genai SDK import (replaces deprecated google.generativeai) try: - import google.generativeai as genai + from google import genai except Exception: # Jarvis: Gemini embeddings unavailable without google.genai genai = None +# python-dotenv for .env file support +try: + from dotenv import load_dotenv + HAS_DOTENV = True + load_dotenv() # Load .env file from project root +except ImportError: + HAS_DOTENV = False + def get_base_dir() -> Path: if getattr(sys, "frozen", False): @@ -33,6 +46,35 @@ def get_base_dir() -> Path: EMBEDDING_MODEL = "text-embedding-004" +def _get_env_api_key(key_name: str) -> str | None: + """ + Load API key from environment variable with fallback to JSON config. + Prioritizes .env file, then os.environ, then api_keys.json. + """ + # First try environment variable (from .env or system) + value = os.getenv(key_name) + if value: + return value + + # Fallback to JSON config if .env not available + api_path = BASE_DIR / "config" / "api_keys.json" + if api_path.exists(): + try: + data = json.loads(api_path.read_text(encoding="utf-8")) + # Map JSON keys to environment variable names + key_map = { + "gemini_api_key": "GEMINI_API_KEY", + "openrouter_api_key": "OPENROUTER_API_KEY", + "serper_api_key": "SERPER_API_KEY", + } + if key_name in key_map: + return data.get(key_map[key_name]) + return data.get(key_name.lower()) + except Exception as exc: + print(f"[Jarvis Memory] ⚠️ Failed to load API key from JSON: {exc}") # Jarvis: JSON fallback failed + return None + + class JarvisMemory: def __init__(self) -> None: self.memory_dir = MEMORY_DIR @@ -43,6 +85,7 @@ def __init__(self) -> None: self._chroma_client = None self._chroma_collection = None self._genai_client = None + self._api_key = None self._init_short_term_db() self._init_genai_client() @@ -71,34 +114,35 @@ def _init_short_term_db(self) -> None: print(f"[Jarvis Memory] ⚠️ Failed to initialize short-term SQLite: {exc}") # Jarvis: fallback to no short-term store self._short_term_conn = None - def _load_api_key(self) -> str | None: - api_path = BASE_DIR / "config" / "api_keys.json" - if not api_path.exists(): - return None - try: - data = json.loads(api_path.read_text(encoding="utf-8")) - return data.get("gemini_api_key") - except Exception as exc: - print(f"[Jarvis Memory] ⚠️ Failed to load API key: {exc}") # Jarvis: Gemini key load failed - return None - def _init_genai_client(self) -> None: + """ + Initialize the modern google-genai SDK client. + Replaces deprecated google.generativeai.Client initialization. + """ if genai is None: self._genai_client = None return - api_key = self._load_api_key() - if not api_key: + # Get API key from environment (prioritizing .env file) + self._api_key = _get_env_api_key("GEMINI_API_KEY") + if not self._api_key: self._genai_client = None + print("[Jarvis Memory] ⚠️ GEMINI_API_KEY not found") # Jarvis: no API key available return try: - self._genai_client = genai.Client(api_key=api_key) + # Modern 2026 SDK Client initialization + self._genai_client = genai.Client(api_key=self._api_key) + print("[Jarvis Memory] ✅ Google GenAI SDK client initialized") # Jarvis: Gemini SDK ready except Exception as exc: print(f"[Jarvis Memory] ⚠️ Failed to initialize Gemini client: {exc}") # Jarvis: embedding service unavailable self._genai_client = None def _embed_text(self, text: str) -> list[float] | None: + """ + Generate embeddings using the modern google-genai SDK. + Uses text-embedding-004 model with updated API format. + """ if self._genai_client is None: return None @@ -107,29 +151,28 @@ def _embed_text(self, text: str) -> list[float] | None: return None try: + # Modern 2026 API format for text embeddings response = self._genai_client.models.embed_content( model=EMBEDDING_MODEL, contents=[cleaned_text], ) - embeddings = None - if hasattr(response, "embeddings"): - embeddings = response.embeddings - elif isinstance(response, dict): - embeddings = response.get("embeddings") or response.get("data") - - if not embeddings: - return None - - first_embedding = embeddings[0] - if isinstance(first_embedding, dict): - return first_embedding.get("embedding") - if hasattr(first_embedding, "embedding"): - return first_embedding.embedding - if isinstance(first_embedding, list): - return first_embedding + + # Extract embedding values from the response + if hasattr(response, "embeddings") and response.embeddings: + first_embedding = response.embeddings[0] + # Modern SDK returns values as a property + if hasattr(first_embedding, "values"): + return first_embedding.values + if hasattr(first_embedding, "embedding"): + return first_embedding.embedding + if isinstance(first_embedding, list): + return first_embedding + + print("[Jarvis Memory] ⚠️ Failed to extract embedding from response") # Jarvis: embedding extraction failed return None + except Exception as exc: - print(f"[Jarvis Memory] ⚠️ Failed to create embedding: {exc}") + print(f"[Jarvis Memory] ⚠️ Failed to create embedding: {exc}") # Jarvis: embedding service unavailable return None def _create_chroma_client(self): diff --git a/memory/rag_processor.py b/memory/rag_processor.py index e2e62ad2..354ebf5b 100644 --- a/memory/rag_processor.py +++ b/memory/rag_processor.py @@ -1,14 +1,24 @@ +# memory/rag_processor.py +# Updated with modern google-genai SDK and .env configuration support + import json +import os from pathlib import Path from typing import Any +# Modern google-genai SDK import (v1.0+) try: - import google.generativeai as genai + from google import genai except ImportError: - try: - from google import genai - except Exception: - genai = None + genai = None + +# Try to load .env configuration +try: + from dotenv import load_dotenv + load_dotenv() # Load from project root + HAS_DOTENV = True +except ImportError: + HAS_DOTENV = False try: import memory.memory_manager as memory_module @@ -32,15 +42,36 @@ def __init__(self, model_name: str | None = None) -> None: self.memory = self._init_memory() self.model = self._init_llm() - def _load_api_key(self) -> str | None: + def _get_env_api_key(self, key_name: str) -> str | None: + """ + Load API key from environment variable with fallback to JSON config. + Prioritizes .env file, then os.environ, then api_keys.json. + """ + # First try environment variable (from .env or system) + value = os.getenv(key_name) + if value: + return value + + # Fallback to JSON config if .env not available config_path = self.root_dir / "config" / "api_keys.json" - try: - with open(config_path, "r", encoding="utf-8") as handle: - data = json.load(handle) - return data.get("gemini_api_key") - except Exception as exc: - print(f"[Jarvis RAG] ⚠️ Could not load API key: {exc}") # Sir, I failed to locate the Gemini API key - return None + if config_path.exists(): + try: + data = json.loads(config_path.read_text(encoding="utf-8")) + key_map = { + "gemini_api_key": "GEMINI_API_KEY", + "openrouter_api_key": "OPENROUTER_API_KEY", + "serper_api_key": "SERPER_API_KEY", + } + if key_name in key_map: + return data.get(key_map[key_name]) + return data.get(key_name.lower()) + except Exception as exc: + print(f"[Jarvis RAG] ⚠️ Failed to load API key from JSON: {exc}") + return None + + def _load_api_key(self) -> str | None: + """Load API key from environment or JSON config.""" + return self._get_env_api_key("GEMINI_API_KEY") def _init_memory(self) -> Any: if memory_module is None: @@ -54,6 +85,10 @@ def _init_memory(self) -> Any: return None def _init_llm(self) -> Any: + """ + Initialize the modern google-genai SDK client. + Replaces deprecated google.generativeai configuration. + """ if genai is None: print("[Jarvis RAG] ⚠️ Google GenAI SDK is not installed.") # Sir, I cannot access the language model return None @@ -63,8 +98,10 @@ def _init_llm(self) -> Any: return None try: - genai.configure(api_key=self.api_key) - return genai.GenerativeModel(model_name=self.model_name) + # Modern 2026 SDK Client initialization + self.client = genai.Client(api_key=self.api_key) + # Create model instance using the modern API + return self.client.models.generate_content except Exception as exc: print(f"[Jarvis RAG] ⚠️ Failed to initialize LLM: {exc}") # Sir, I failed to create the Gemini model client return None @@ -155,12 +192,23 @@ def _build_augmented_prompt( return "\n".join(prompt_parts) def _generate_response(self, prompt: str) -> str | None: + """ + Generate response using the modern google-genai SDK. + """ if self.model is None: print("[Jarvis RAG] ⚠️ LLM client is unavailable.") # Sir, I cannot generate a response without a model return None try: - response = self.model.generate_content(prompt) + # Modern API call format + response = self.model( + model=self.model_name, + contents=prompt, + config={ + "temperature": 0.7, + "max_output_tokens": 1024, + }, + ) if not response or not getattr(response, "text", None): return None return str(response.text).strip() diff --git a/or_client.py b/or_client.py index 08be1d2c..521ba032 100644 --- a/or_client.py +++ b/or_client.py @@ -1,4 +1,5 @@ import json +import os import sys import time import base64 @@ -11,6 +12,15 @@ logging.basicConfig(level=logging.INFO) logger = logging.getLogger("openrouter_client") +# Try to load .env configuration +try: + from dotenv import load_dotenv + load_dotenv() # Load from project root + HAS_DOTENV = True +except ImportError: + HAS_DOTENV = False + + def _get_base_dir() -> Path: if getattr(sys, "frozen", False): return Path(sys.executable).parent @@ -20,7 +30,35 @@ def _get_base_dir() -> Path: BASE_DIR = _get_base_dir() API_KEY_PATH = BASE_DIR / "config" / "api_keys.json" + +def _get_env_api_key(key_name: str) -> str | None: + """ + Load API key from environment variable with fallback to JSON config. + Prioritizes .env file, then os.environ, then api_keys.json. + """ + # First try environment variable (from .env or system) + value = os.getenv(key_name) + if value: + return value + + # Fallback to JSON config if .env not available + if API_KEY_PATH.exists(): + try: + data = json.loads(API_KEY_PATH.read_text(encoding="utf-8")) + return data.get(key_name.lower(), "") + except Exception as exc: + logger.warning(f"[OpenRouter] Failed to load API key from JSON: {exc}") + return None + + def _load_api_key() -> str: + """Load OpenRouter API key from environment or JSON config.""" + # First try environment variable + key = _get_env_api_key("OPENROUTER_API_KEY") + if key: + return key + + # Fallback to JSON config try: with open(API_KEY_PATH, "r", encoding="utf-8") as f: data = json.load(f) From dbdbe8eb2cb67115caf4f55115ab38267cec84da Mon Sep 17 00:00:00 2001 From: Mayura Bandara <128353336+SLxnoat@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:00:02 +0530 Subject: [PATCH 09/12] Update .gitignore to include .env file for environment variable management --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index ef281257..c063ba8e 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ chroma_db/ # API keys config/api_keys.json + +.env \ No newline at end of file From d4b7947b708e9cc1e249607f92f38df0bf379fa6 Mon Sep 17 00:00:00 2001 From: Mayura Bandara <128353336+SLxnoat@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:03:18 +0530 Subject: [PATCH 10/12] Update .gitignore to include additional database files and environment variables --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 44bc7d55..6c2807b6 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,7 @@ jarvis-env/ # API keys config/api_keys.json + +.env +jarvis_memory/chroma.sqlite3 +jarvis_memory/short_term.db From 52dc4fece90909c21b792132c0bebbf7967f591b Mon Sep 17 00:00:00 2001 From: Mayura Bandara <128353336+SLxnoat@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:47:31 +0530 Subject: [PATCH 11/12] chore: remove outdated readme file --- readme.md | 82 ------------------------------------------------------- 1 file changed, 82 deletions(-) delete mode 100644 readme.md diff --git a/readme.md b/readme.md deleted file mode 100644 index c21674d8..00000000 --- a/readme.md +++ /dev/null @@ -1,82 +0,0 @@ -# 🤖 MARK XXXIX-OR (39) -### The Ultimate Cross-Platform Personal AI Assistant — By FatihMakes - -> 📺 **[Watch the full setup video on YouTube](https://youtu.be/ldvDNzwnM8k)** - -A real-time voice AI that can hear, see, understand, and control your computer — on any OS. Supporting Windows, macOS, and Linux. Local execution. Zero subscriptions. Engineered for total autonomy. - ---- - -## ✨ Overview - -MARK XXXIX-OR represents the pinnacle of the Jarvis series, evolving into a more flexible and robust system. It bridges the gap between the operating system and human intent. Through natural dialogue, Mark 39 analyzes your screen, processes uploaded documents, and executes complex workflows with a brand-new, adaptive interface. - -It's not just an assistant — it's an extension of your digital life. - ---- - -## 🚀 Capabilities - -### Core Features -| Feature | Description | -|---|---| -| 🎙️ Real-time Voice | Ultra-low latency conversation in any language | -| 🖥️ System Control | Launch apps, manage files, execute terminal commands | -| 🧩 Autonomous Tasks | High-level planning for complex, multi-step goals | -| 👁️ Visual Awareness | Real-time screen processing and webcam vision | -| 🧠 Persistent Memory | Deeply remembers your projects, preferences, and personal context | -| ⌨️ Hybrid Input | Seamlessly switch between keyboard typing and voice commands | - ---- - -## 🆕 What's New in XXXIX-OR - -- 📂 **Advanced File Handling** — New support for direct file uploads. Drop PDFs, source code, or images into the assistant to have them analyzed, summarized, or edited instantly. -- 🎨 **Adaptive & Flexible UI** — A complete overhaul of the interface. The new UI is fully resizable and responsive, featuring transparency controls and customizable layouts to fit your workspace perfectly. -- 🐧🍎 **Refined Cross-Platform Stability** — Major fixes for macOS and Linux compatibility. Core system actions are now more consistent across all three major operating systems. -- ⚡ **Optimized Core Engine** — Significant performance boost in tool-calling logic and response generation, resulting in a 40% faster interaction speed. -- 🔀 **OpenRouter Integration** — Selected action modules (web search, memory, flight finder, desktop control, and more) now route their LLM calls through OpenRouter's free-tier models. This significantly increases the effective request limit without any additional cost, while Gemini Live continues to handle real-time voice and tool-calling. - ---- - -## ⚡ Quick Start - -```bash -git clone https://github.com/FatihMakes/Mark-XXXIX-OR.git -cd Mark-XXXIX-OR -pip install -r requirements.txt -playwright install -python main.py -``` - -> ⚠️ **Installation Note:** To keep the repository lightweight, some OS-specific dependencies are not bundled in `requirements.txt`. If you run into a `ModuleNotFoundError`, simply install the missing package via `pip install ` for your specific system. - ---- - -## 📋 Requirements - -| Requirement | Details | -|---|---| -| **OS** | Windows 10/11, macOS, or Linux | -| **Python** | 3.11 or 3.12 | -| **Microphone** | Required for voice interaction | -| **API Keys** | Free Gemini API key + Free OpenRouter API key | - ---- - -## ⚠️ License - -Personal and non-commercial use only. -Licensed under **[Creative Commons BY-NC 4.0](https://creativecommons.org/licenses/by-nc/4.0/)**. - ---- - -## 👤 Connect with the Creator - -Engineered by a developer building a real-world JARVIS-style assistant. -⭐ **Star the repository to support the journey to Mark 100.** - -| Platform | Link | -|---|---| -| YouTube | [@FatihMakes](https://www.youtube.com/@FatihMakes) | -| Instagram | [@fatihmakes](https://www.instagram.com/fatihmakes) | From c0b3b27aac5935913d7405b2f096e87a31961b45 Mon Sep 17 00:00:00 2001 From: Mayura Bandara <128353336+SLxnoat@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:32:27 +0530 Subject: [PATCH 12/12] refactor: streamline web search functionality and integrate unified config loader for API keys --- actions/web_search.py | 107 +++++++++++++++------------------------ config/loader.py | 94 ++++++++++++++++++++++++++++++++++ main.py | 62 +++++++++++++++++++++-- memory/memory_manager.py | 95 +++++++++++++++++----------------- 4 files changed, 240 insertions(+), 118 deletions(-) create mode 100644 config/loader.py diff --git a/actions/web_search.py b/actions/web_search.py index ef90a4f9..f8df740b 100644 --- a/actions/web_search.py +++ b/actions/web_search.py @@ -1,6 +1,6 @@ # web_search.py -# Hybrid Omni-Search & Playwright Stealth Deep Scraper with ChromaDB RAG Integration -# Replaces old OpenRouter LLM-based search with Serper API + async deep scraping +# Hybrid Omni-Search & Playwright Stealth Deep Scraper +# Returns search results and scraped content; memory persistence handled by main.py import asyncio import json @@ -12,10 +12,11 @@ # Try to import playwright_stealth for bypassing anti-bot protections try: - import playwright_stealth + from playwright_stealth import stealth_async HAS_STEALTH = True except ImportError: HAS_STEALTH = False + stealth_async = None # Try to import playwright async API try: @@ -24,30 +25,10 @@ except ImportError: HAS_PLAYWRIGHT = False -BASE_DIR: Path = Path(__file__).resolve().parent.parent -API_CONFIG_PATH: Path = BASE_DIR / "config" / "api_keys.json" -SERPER_API_URL: str = "https://google.serper.dev/search" - -# Try to import playwright async API -try: - from playwright.async_api import async_playwright - HAS_PLAYWRIGHT = True -except ImportError: - HAS_PLAYWRIGHT = False - -def _get_api_key() -> str: - """Load Gemini API key from config.""" - with open(API_CONFIG_PATH, "r", encoding="utf-8") as f: - return json.load(f)["gemini_api_key"] - +from config.loader import get_base_dir, get_serper_api_key, get_openrouter_api_key -def _get_serper_api_key() -> str: - """Load Serper API key from config, or use fallback.""" - try: - with open(API_CONFIG_PATH, "r", encoding="utf-8") as f: - return json.load(f).get("serper_api_key", "") - except Exception: - return "" +BASE_DIR: Path = get_base_dir() +SERPER_API_URL: str = "https://google.serper.dev/search" def _serper_search(query: str, max_results: int = 3) -> list[dict]: @@ -55,24 +36,31 @@ def _serper_search(query: str, max_results: int = 3) -> list[dict]: Query the Serper API for search results. Returns top organic search result URLs and metadata. """ - serper_key = _get_serper_api_key() + serper_key = get_serper_api_key() if not serper_key: # Fallback to OpenRouter if no Serper key - try: - from or_client import client - result = client.chat( - f"Search the web for: {query}", - system="You are a web search assistant. Provide factually accurate results with sources.", - ) - return [ - { - "title": "Search Result", - "snippet": result[:500], - "url": "https://example.com/search-result", - } - ] - except Exception as e: - print(f"[WebSearch] ⚠️ Serper key missing and OpenRouter failed: {e}") + openrouter_key = get_openrouter_api_key() + if openrouter_key: + try: + from or_client import Client + client = Client(api_key=openrouter_key) + result = client.chat( + f"Search the web for: {query}", + system="You are a web search assistant. Provide factually accurate results with sources.", + max_tokens=500, + ) + return [ + { + "title": "Search Result", + "snippet": result[:500], + "url": "https://example.com/search-result", + } + ] + except Exception as e: + print(f"[WebSearch] ⚠️ OpenRouter search failed: {e}") + return [] + else: + print("[WebSearch] ⚠️ Serper key missing and OpenRouter key unavailable") return [] headers = { @@ -128,8 +116,8 @@ async def _scrape_page_with_stealth( """ try: # Apply playwright_stealth to bypass anti-bot protections - if HAS_STEALTH: - await playwright_stealth.stealth_async(page) + if HAS_STEALTH and stealth_async: + await stealth_async(page) # Set realistic User-Agent await page.set_extra_http_headers({ @@ -258,12 +246,11 @@ def _clean_scraped_content(text: str) -> str: return text -async def _deep_scrape_and_store( - search_results: list[dict], -) -> list[dict]: +async def _deep_scrape(search_results: list[dict]) -> list[dict]: """ - Deep scrape top 3 URLs and store in ChromaDB via RAG. + Deep scrape top 3 URLs without memory storage. Returns enriched results with scraped content. + Memory persistence is handled by the caller (main.py). """ if not search_results: return [] @@ -283,25 +270,12 @@ async def _deep_scrape_and_store( # Clean content clean_content = _clean_scraped_content(content) if content else "" - # Store in ChromaDB for long-term memory - try: - from memory.memory_manager import JarvisMemory - - memory = JarvisMemory() - metadata = {"url": url, "title": title} - - if clean_content: - memory.store_permanent_fact(clean_content, metadata=metadata) - print(f"[WebSearch] ✅ Stored {url} in ChromaDB memory") - - except Exception as e: - print(f"[WebSearch] ⚠️ Failed to store {url} in memory: {e}") - enriched_results.append({ "title": title, - "snippet": clean_content[:500] if clean_content else "", # Use scraped content as snippet + "snippet": clean_content[:500] if clean_content else "", "url": url, "full_content": clean_content, + "metadata": {"url": url, "title": title}, }) return enriched_results @@ -356,6 +330,7 @@ def web_search_action(parameters: dict, player=None) -> str: """ Main synchronous wrapper function for web_search. Managed through asyncio.run() for compatibility with Jarvis's thread-safe executor. + Returns formatted search results. Memory storage is handled by main.py. """ params = parameters or {} query = params.get("query", "").strip() @@ -379,7 +354,7 @@ def web_search_action(parameters: dict, player=None) -> str: # Step 2: Deep scrape the top 3 URLs concurrently async def run_scrape(): - return await _deep_scrape_and_store(search_results) + return await _deep_scrape(search_results) scraped_results = asyncio.run(run_scrape()) @@ -395,10 +370,10 @@ async def run_scrape(): return f"Search failed for '{query}', sir: {e}" -# Legacy function for compatibility -def web_search(parameters: dict, response=None, player=None, session_memory=None) -> str: +def web_search(parameters: dict, player=None) -> str: """ Legacy wrapper function - now delegates to web_search_action. + Kept for backward compatibility. """ return web_search_action(parameters, player) diff --git a/config/loader.py b/config/loader.py new file mode 100644 index 00000000..c8b4a3ca --- /dev/null +++ b/config/loader.py @@ -0,0 +1,94 @@ +# config/loader.py +# Unified configuration loader with API key management + +import json +import os +from pathlib import Path + +try: + from dotenv import load_dotenv + load_dotenv() + HAS_DOTENV = True +except ImportError: + HAS_DOTENV = False + + +def get_base_dir() -> Path: + """Get base directory of the project.""" + import sys + if getattr(sys, "frozen", False): + return Path(sys.executable).parent + return Path(__file__).resolve().parent.parent + + +def get_api_config_path() -> Path: + """Get path to api_keys.json config file.""" + return get_base_dir() / "config" / "api_keys.json" + + +def _load_api_keys_json() -> dict: + """Load API keys from JSON config file.""" + api_path = get_api_config_path() + if not api_path.exists(): + return {} + try: + return json.loads(api_path.read_text(encoding="utf-8")) + except Exception: + return {} + + +def get_api_key(key_name: str) -> str | None: + """ + Load API key from environment variable with fallback to JSON config. + Priority: .env -> os.environ -> config/api_keys.json + + Supports keys: + - GEMINI_API_KEY / gemini_api_key + - SERPER_API_KEY / serper_api_key + - OPENROUTER_API_KEY / openrouter_api_key + """ + # First try environment variable (from .env or system) + value = os.getenv(key_name) + if value: + return value + + # Fallback to JSON config + api_keys = _load_api_keys_json() + + # Map environment variable names to JSON keys + key_map = { + "GEMINI_API_KEY": "gemini_api_key", + "GEMINI_API_KEY.lower()": "gemini_api_key", + "SERPER_API_KEY": "serper_api_key", + "OPENROUTER_API_KEY": "openrouter_api_key", + } + + json_key = key_map.get(key_name, key_name.lower()) + return api_keys.get(json_key) + + +def get_gemini_api_key() -> str | None: + """Get Gemini API key.""" + return get_api_key("GEMINI_API_KEY") + + +def get_serper_api_key() -> str | None: + """Get Serper API key.""" + return get_api_key("SERPER_API_KEY") + + +def get_openrouter_api_key() -> str | None: + """Get OpenRouter API key.""" + return get_api_key("OPENROUTER_API_KEY") + + +def get_or_client(): + """Get OpenRouter client if API key is available.""" + api_key = get_openrouter_api_key() + if not api_key: + return None + try: + from or_client import Client + return Client(api_key=api_key) + except ImportError: + return None diff --git a/main.py b/main.py index b5124740..0b2b8803 100644 --- a/main.py +++ b/main.py @@ -40,6 +40,9 @@ from actions.dev_agent import dev_agent from actions.web_search import web_search as web_search_action from actions.computer_control import computer_control + +# Import memory manager for search result storage +from memory.memory_manager import JarvisMemory from actions.game_updater import game_updater @@ -543,6 +546,14 @@ def __init__(self, ui: JarvisUI, rag_processor: JarvisRAGProcessor | None = None self.ui.on_text_command = self._on_text_command self.rag_processor = rag_processor or JarvisRAGProcessor() + def _drop_oldest_queue_item(self, queue): + """Helper to drop oldest queue item when full.""" + try: + if not queue.empty(): + queue.get_nowait() + except Exception: + pass + def _on_text_command(self, text: str): if not self._loop or not self.session: return @@ -711,6 +722,24 @@ async def _execute_tool(self, fc) -> types.FunctionResponse: r = await loop.run_in_executor(None, lambda: web_search_action(parameters=args, player=self.ui)) result = r or "Done." + # Store search results in memory for RAG + try: + # Parse the result to extract URLs from the formatted output + import re + url_pattern = r'\[?\d+\]\s+.*?\n\s+Source:\s+(\S+)' + urls = re.findall(url_pattern, result) + if urls: + memory = JarvisMemory() + for url in urls: + memory.store_permanent_fact( + f"Search result URL: {url}", + metadata={"source": "web_search", "url": url} + ) + print(f"[Memory] ✅ Stored {len(urls)} search result URLs in ChromaDB") + except Exception as e: + # Non-critical: memory storage failure should not fail the search + print(f"[Memory] ⚠️ Failed to store search results: {e}") + elif name == "computer_control": r = await loop.run_in_executor(None, lambda: computer_control(parameters=args, player=self.ui)) result = r or "Done." @@ -764,10 +793,24 @@ def callback(indata, frames, time_info, status): jarvis_speaking = self._is_speaking if not jarvis_speaking and not self.ui.muted: data = indata.tobytes() - loop.call_soon_threadsafe( - self.out_queue.put_nowait, - {"data": data, "mime_type": "audio/pcm"} - ) + queue_data = {"data": data, "mime_type": "audio/pcm"} + try: + loop.call_soon_threadsafe( + self.out_queue.put_nowait, + queue_data + ) + except asyncio.QueueFull: + # Clear oldest frame to make space for real-time stream + try: + loop.call_soon_threadsafe( + lambda: self._drop_oldest_queue_item(self.out_queue) + ) + loop.call_soon_threadsafe( + self.out_queue.put_nowait, + queue_data + ) + except Exception: + pass # Drop frame if queue remains full try: with sd.InputStream( @@ -793,7 +836,16 @@ async def _receive_audio(self): async for response in self.session.receive(): if response.data: - self.audio_in_queue.put_nowait(response.data) + try: + self.audio_in_queue.put_nowait(response.data) + except asyncio.QueueFull: + # Clear oldest frame to make space for real-time stream + try: + if not self.audio_in_queue.empty(): + self.audio_in_queue.get_nowait() + self.audio_in_queue.put_nowait(response.data) + except Exception: + pass # Drop frame if queue remains full if response.server_content: sc = response.server_content diff --git a/memory/memory_manager.py b/memory/memory_manager.py index a3121dd0..70f4a3b8 100644 --- a/memory/memory_manager.py +++ b/memory/memory_manager.py @@ -1,5 +1,6 @@ # memory/memory_manager.py # Modernized with google-genai SDK (v1.0+) and .env configuration +# Uses unified config loader for API key management import json import os @@ -14,30 +15,23 @@ try: import chromadb from chromadb.config import Settings -except Exception: # Jarvis: long-term vector store unavailable without chromadb +except Exception: chromadb = None Settings = None -# Modern google-genai SDK import (replaces deprecated google.generativeai) try: from google import genai -except Exception: # Jarvis: Gemini embeddings unavailable without google.genai +except Exception: genai = None -# python-dotenv for .env file support try: from dotenv import load_dotenv - HAS_DOTENV = True - load_dotenv() # Load .env file from project root + load_dotenv() except ImportError: - HAS_DOTENV = False - - -def get_base_dir() -> Path: - if getattr(sys, "frozen", False): - return Path(sys.executable).parent - return Path(__file__).resolve().parent.parent + pass +# Import unified config loader +from config.loader import get_base_dir, get_api_key BASE_DIR = get_base_dir() MEMORY_DIR = BASE_DIR / "jarvis_memory" @@ -48,31 +42,10 @@ def get_base_dir() -> Path: def _get_env_api_key(key_name: str) -> str | None: """ - Load API key from environment variable with fallback to JSON config. - Prioritizes .env file, then os.environ, then api_keys.json. + Load API key using unified config loader. + Priority: .env -> os.environ -> config/api_keys.json """ - # First try environment variable (from .env or system) - value = os.getenv(key_name) - if value: - return value - - # Fallback to JSON config if .env not available - api_path = BASE_DIR / "config" / "api_keys.json" - if api_path.exists(): - try: - data = json.loads(api_path.read_text(encoding="utf-8")) - # Map JSON keys to environment variable names - key_map = { - "gemini_api_key": "GEMINI_API_KEY", - "openrouter_api_key": "OPENROUTER_API_KEY", - "serper_api_key": "SERPER_API_KEY", - } - if key_name in key_map: - return data.get(key_map[key_name]) - return data.get(key_name.lower()) - except Exception as exc: - print(f"[Jarvis Memory] ⚠️ Failed to load API key from JSON: {exc}") # Jarvis: JSON fallback failed - return None + return get_api_key(key_name) class JarvisMemory: @@ -152,21 +125,49 @@ def _embed_text(self, text: str) -> list[float] | None: try: # Modern 2026 API format for text embeddings + # Pass text directly as string response = self._genai_client.models.embed_content( model=EMBEDDING_MODEL, - contents=[cleaned_text], + contents=cleaned_text, ) - # Extract embedding values from the response + # Handle response - check for different attribute names + # Try embeddings first (list format) if hasattr(response, "embeddings") and response.embeddings: - first_embedding = response.embeddings[0] - # Modern SDK returns values as a property - if hasattr(first_embedding, "values"): - return first_embedding.values - if hasattr(first_embedding, "embedding"): - return first_embedding.embedding - if isinstance(first_embedding, list): - return first_embedding + embedding = response.embeddings + # Handle list of embeddings + if isinstance(embedding, list) and len(embedding) > 0: + first_embedding = embedding[0] + if hasattr(first_embedding, "values"): + return first_embedding.values + if hasattr(first_embedding, "embedding"): + return first_embedding.embedding + if isinstance(first_embedding, (list, tuple)): + return list(first_embedding) + + # Try embedding attribute (single embedding format) + if hasattr(response, "embedding"): + embedding = response.embedding + if hasattr(embedding, "values"): + return embedding.values + if hasattr(embedding, "embedding"): + return embedding.embedding + if isinstance(embedding, (list, tuple)): + return list(embedding) + + # Handle dict-like response + if hasattr(response, "__dict__"): + for attr_name in ["embeddings", "embedding"]: + attr = getattr(response, attr_name, None) + if attr is not None: + if isinstance(attr, list) and len(attr) > 0: + item = attr[0] + if hasattr(item, "values"): + return list(item.values) + if hasattr(item, "embedding"): + return list(item.embedding) + if isinstance(item, (list, tuple)): + return list(item) print("[Jarvis Memory] ⚠️ Failed to extract embedding from response") # Jarvis: embedding extraction failed return None