diff --git a/memory_thread.egg-info/SOURCES.txt b/memory_thread.egg-info/SOURCES.txt index 7cc64b9..4691ae1 100644 --- a/memory_thread.egg-info/SOURCES.txt +++ b/memory_thread.egg-info/SOURCES.txt @@ -61,9 +61,11 @@ memory_thread/services/assimilator.py memory_thread/services/async_wal.py memory_thread/services/belief_store.py memory_thread/services/classify_service.py +memory_thread/services/code_intelligence.py memory_thread/services/contemplator.py memory_thread/services/decay_engine.py memory_thread/services/decay_service.py +memory_thread/services/document_intelligence.py memory_thread/services/extract_service.py memory_thread/services/fact_store.py memory_thread/services/file_ingest_service.py diff --git a/memory_thread/cli.py b/memory_thread/cli.py index 9e63eb5..9f91acb 100644 --- a/memory_thread/cli.py +++ b/memory_thread/cli.py @@ -25,6 +25,7 @@ import sys import json import typer +import requests from typing import Optional, List from enum import IntEnum from rich.console import Console @@ -62,11 +63,13 @@ provider_app = typer.Typer(help="Manage LLM providers. [dim]B-CLASS[/dim]") galaxy_app = typer.Typer(help="Galaxy Schema inspection. [dim]B-CLASS[/dim]") clients_app = typer.Typer(help="API client management. [dim]S-CLASS[/dim]") +ollama_app = typer.Typer(help="Manage local Ollama models. [dim]E-CLASS[/dim]") app.add_typer(agent_app, name="agent") app.add_typer(provider_app, name="provider") app.add_typer(galaxy_app, name="galaxy") app.add_typer(clients_app, name="clients") +app.add_typer(ollama_app, name="ollama") # ═══════════════════════════════════════════════════════════════════════════════ @@ -626,6 +629,64 @@ def provider_remove(name: str = typer.Argument(..., help="Provider to remove")): console.print(f"[red]✘ {e}[/red]") +# --- Ollama sub-commands --- + +@ollama_app.command("scan") +def ollama_scan(): + """Scan for local Ollama models.""" + _require(Grade.E_CLASS, "ollama scan") + try: + url = "http://localhost:11434/api/tags" + resp = requests.get(url, timeout=5) + if resp.status_code == 200: + models = resp.json().get("models", []) + if not models: + console.print("[yellow]No models found in Ollama.[/yellow]") + return + + table = Table(title="Local Ollama Models", show_lines=False) + table.add_column("Name", style="cyan") + table.add_column("Size", style="dim") + table.add_column("Modified") + + for m in models: + size_gb = m.get("size", 0) / (1024**3) + table.add_row(m["name"], f"{size_gb:.1f} GB", m.get("modified_at", "")[:10]) + + console.print(table) + console.print("[dim]Use 'mt ollama use ' to select one.[/dim]") + else: + console.print(f"[red]Ollama API error: {resp.status_code}[/red]") + except requests.exceptions.ConnectionError: + console.print("[red]✘ Could not connect to Ollama (localhost:11434)[/red]") + console.print("[dim]Is 'ollama serve' running?[/dim]") + except Exception as e: + console.print(f"[red]✘ {e}[/red]") + + +@ollama_app.command("list") +def ollama_list(): + """Alias for scan.""" + ollama_scan() + + +@ollama_app.command("use") +def ollama_use( + model: str = typer.Argument(..., help="Model name (e.g., llama3)"), +): + """Set Ollama as the active provider with this model.""" + _require(Grade.E_CLASS, "ollama use") + try: + from memory_thread.nervous.vault import vault + # Store config for ollama + # API key is dummy for ollama + vault.set_provider("ollama", "local", "http://localhost:11434", model, _user()) + vault.set_active_provider("ollama", _user()) + console.print(f"[green]✔ Switched to Ollama (model: {model})[/green]") + except Exception as e: + console.print(f"[red]✘ {e}[/red]") + + # ═══════════════════════════════════════════════════════════════════════════════ # A_CLASS: BRAIN TUNING (Researcher+) # ═══════════════════════════════════════════════════════════════════════════════ diff --git a/memory_thread/sdk.py b/memory_thread/sdk.py index a7e6633..aa2f6f7 100644 --- a/memory_thread/sdk.py +++ b/memory_thread/sdk.py @@ -14,6 +14,8 @@ """ import uuid import json +import os +import requests from datetime import datetime from typing import Optional, List, Dict, Any, Tuple from dataclasses import dataclass, field @@ -22,6 +24,7 @@ from memory_thread.services.tms_service import TMSService, TruthVectorService, StateDerivationService from memory_thread.config.settings import settings from memory_thread.utils.logger import get_logger +from memory_thread.nervous.vault import vault log = get_logger(__name__) @@ -120,6 +123,7 @@ def _init_db_clients(self): self._db_type = "postgres" log.info("PostgreSQL connected") except Exception as e: + # Clean logging: only warn in file, not console (unless debug) log.warning(f"PostgreSQL unavailable: {e}. Trying SQLite...") self._pg = None @@ -141,6 +145,7 @@ def _init_db_clients(self): self._ensure_collection() log.info("Qdrant connected") except Exception as e: + # Clean logging log.warning(f"Qdrant unavailable: {e}. Using keyword search.") self._qdrant = None @@ -1075,7 +1080,7 @@ def chat( Args: user_message: User's input system_prompt: Optional system prompt - use_local: If True, use local SmolLM. If False, use cloud API. + use_local: If True, prefer local models (Ollama/SmolLM). Returns: LLM response with memory context @@ -1110,19 +1115,58 @@ def chat( User: {user_message} Assistant:""" - # 5. Generate response - if use_local: - response = self._generate_local(full_prompt) + # 5. Determine Provider via Vault + # Priority: + # 1. Env vars (legacy overrides) + # 2. Vault active provider + # 3. Default fallback (SmolLM) + + user_id = os.environ.get("MT_USER", "default") + active_provider = vault.get_active_provider(user_id) + + log.info(f"Generating response using provider: {active_provider}") + + if active_provider == "ollama": + # Get configured model for ollama, or default + creds = vault.get_provider("ollama", user_id) + model = creds.get("model") if creds else "llama3" + response = self._generate_ollama(full_prompt, model=model) + + elif active_provider in ["groq", "openrouter", "openai"]: + response = self._generate_cloud(full_prompt, provider=active_provider) + else: - response = self._generate_cloud(full_prompt) + # Fallback to SmolLM (local transformers) + response = self._generate_smollm(full_prompt) # 6. Remember agent response (lower authority) self.remember(response, source="agent", confidence=0.7, authority=0.5) return response - def _generate_local(self, prompt: str) -> str: - """Generate response using local SmolLM.""" + def _generate_ollama(self, prompt: str, model: str = "llama3") -> str: + """Generate response using local Ollama instance.""" + try: + url = "http://localhost:11434/api/generate" + payload = { + "model": model, + "prompt": prompt, + "stream": False + } + + resp = requests.post(url, json=payload, timeout=60) + if resp.status_code == 200: + return resp.json().get("response", "") + else: + log.warning(f"Ollama error {resp.status_code}: {resp.text}") + return f"[Ollama failed ({resp.status_code}). Falling back...]" + + except Exception as e: + log.warning(f"Ollama connection failed: {e}") + return f"[Ollama unavailable. ensure 'ollama serve' is running.]" + + def _generate_smollm(self, prompt: str) -> str: + """Generate response using local SmolLM (Transformers).""" try: from transformers import AutoTokenizer, AutoModelForCausalLM import torch @@ -1162,34 +1206,26 @@ def _generate_local(self, prompt: str) -> str: def _generate_cloud(self, prompt: str, provider: str = "auto") -> str: """ Generate response using cloud API. - - Providers: - - "groq": Uses GROQ_MODEL (default: llama-3.1-70b-versatile) - - "openrouter": Uses OPENROUTER_MODEL (default: meta-llama/llama-3.1-405b-instruct) - - "auto": Try Groq first, then OpenRouter, then local - - Set via environment variables: - - GROQ_API_KEY, GROQ_MODEL - - OPENROUTER_API_KEY, OPENROUTER_MODEL + Checks Vault for credentials first, then Env Vars. """ try: - import os import requests - groq_key = os.environ.get("GROQ_API_KEY") - groq_model = os.environ.get("GROQ_MODEL", "llama-3.3-70b-versatile") - openrouter_key = os.environ.get("OPENROUTER_API_KEY") - openrouter_model = os.environ.get("OPENROUTER_MODEL", "meta-llama/llama-3.1-405b-instruct") + user_id = os.environ.get("MT_USER", "default") - # Provider selection - if provider == "groq" or (provider == "auto" and groq_key): - if groq_key: - log.info(f"Using Groq ({groq_model})") + # Groq + if provider == "groq" or provider == "auto": + creds = vault.get_provider("groq", user_id) + key = creds.get("api_key") if creds else os.environ.get("GROQ_API_KEY") + model = creds.get("model") if creds else os.environ.get("GROQ_MODEL", "llama-3.3-70b-versatile") + + if key: + log.info(f"Using Groq ({model})") response = requests.post( "https://api.groq.com/openai/v1/chat/completions", - headers={"Authorization": f"Bearer {groq_key}"}, + headers={"Authorization": f"Bearer {key}"}, json={ - "model": groq_model, + "model": model, "messages": [{"role": "user", "content": prompt}], "max_tokens": 500, "temperature": 0.7 @@ -1199,21 +1235,25 @@ def _generate_cloud(self, prompt: str, provider: str = "auto") -> str: if response.ok: return response.json()["choices"][0]["message"]["content"] else: - log.warning(f"Groq error: {response.status_code} - {response.text[:100]}") - - # Try OpenRouter - if provider == "openrouter" or (provider == "auto" and openrouter_key): - if openrouter_key: - log.info(f"Using OpenRouter ({openrouter_model})") + log.warning(f"Groq error: {response.status_code}") + + # OpenRouter + if provider == "openrouter" or provider == "auto": + creds = vault.get_provider("openrouter", user_id) + key = creds.get("api_key") if creds else os.environ.get("OPENROUTER_API_KEY") + model = creds.get("model") if creds else os.environ.get("OPENROUTER_MODEL", "meta-llama/llama-3.1-405b-instruct") + + if key: + log.info(f"Using OpenRouter ({model})") response = requests.post( "https://openrouter.ai/api/v1/chat/completions", headers={ - "Authorization": f"Bearer {openrouter_key}", + "Authorization": f"Bearer {key}", "HTTP-Referer": "https://github.com/badalraj9/MemoryThread", "X-Title": "MemoryThread" }, json={ - "model": openrouter_model, + "model": model, "messages": [{"role": "user", "content": prompt}], "max_tokens": 500, "temperature": 0.7 @@ -1223,17 +1263,15 @@ def _generate_cloud(self, prompt: str, provider: str = "auto") -> str: if response.ok: return response.json()["choices"][0]["message"]["content"] else: - log.warning(f"OpenRouter error: {response.status_code} - {response.text[:100]}") - + log.warning(f"OpenRouter error: {response.status_code}") + # Fallback to local - log.warning("No cloud API available, falling back to local model") - return self._generate_local(prompt) + log.warning("No cloud API available/configured, falling back to local model") + return self._generate_smollm(prompt) except Exception as e: log.error(f"Cloud generation failed: {e}") - return self._generate_local(prompt) - - # ========== GALAXY SCHEMA METHODS (Layer 3) ========== + return self._generate_smollm(prompt) def ingest_fact( self, @@ -1391,4 +1429,3 @@ def galaxy_stats(self) -> dict: def create_memory_client(namespace: str = "default", use_db: bool = True) -> MemoryClient: """Create a new MemoryClient instance.""" return MemoryClient(namespace=namespace, use_db=use_db) - diff --git a/memory_thread/utils/cli_bridge.py b/memory_thread/utils/cli_bridge.py index f01d75d..13d2115 100644 --- a/memory_thread/utils/cli_bridge.py +++ b/memory_thread/utils/cli_bridge.py @@ -11,6 +11,7 @@ from textual.binding import Binding import shlex import os +import requests class MTShell(App): @@ -135,6 +136,7 @@ async def _handle_chat(self, message: str): # Get context and generate response try: + # Chat automatically selects Ollama via vault now response = client.chat(message, use_local=True) except Exception: # Fallback if chat fails @@ -192,6 +194,7 @@ async def _handle_command(self, cmd_line: str): "stream": self._cmd_stream, "galaxy": self._cmd_galaxy, "provider": self._cmd_provider, + "ollama": self._cmd_ollama, "secure": self._cmd_secure, "quit": self._cmd_quit, "exit": self._cmd_quit, @@ -264,6 +267,10 @@ async def _cmd_help(self, args) -> str: /agent use Switch agent /conflicts Show conflicts +OLLAMA: + /ollama scan List local models + /ollama use Switch to model + /quit Exit shell""" async def _cmd_recall(self, args) -> str: @@ -676,6 +683,47 @@ async def _cmd_provider(self, args) -> str: except Exception as e: return f"[ERR] {e}" + async def _cmd_ollama(self, args) -> str: + """Handle Ollama commands.""" + if not args: + return "Usage: /ollama [args]" + + subcmd = args[0].lower() + + try: + if subcmd == "scan": + url = "http://localhost:11434/api/tags" + try: + resp = requests.get(url, timeout=5) + if resp.status_code == 200: + models = resp.json().get("models", []) + if not models: + return "No models found in Ollama." + lines = ["Local Ollama Models:"] + for m in models: + size_gb = m.get("size", 0) / (1024**3) + lines.append(f" {m['name']} ({size_gb:.1f} GB)") + return "\n".join(lines) + else: + return f"[ERR] Ollama API error: {resp.status_code}" + except requests.exceptions.ConnectionError: + return "[ERR] Could not connect to Ollama (localhost:11434). Is it running?" + + elif subcmd == "use": + if len(args) < 2: + return "Usage: /ollama use " + model = args[1] + from memory_thread.nervous.vault import vault + vault.set_provider("ollama", "local", "http://localhost:11434", model, self._user_id) + vault.set_active_provider("ollama", self._user_id) + return f"[OK] Switched to Ollama (model: {model})" + + else: + return "Usage: /ollama [scan|use]" + + except Exception as e: + return f"[ERR] {e}" + async def _cmd_secure(self, args) -> str: """Toggle secure mode for entering sensitive data.""" if args and args[0].lower() == "off": @@ -747,4 +795,4 @@ def action_clear_log(self): if __name__ == "__main__": app = MTShell() - app.run() \ No newline at end of file + app.run() diff --git a/memory_thread/utils/logger.py b/memory_thread/utils/logger.py index a243ae3..d2713af 100644 --- a/memory_thread/utils/logger.py +++ b/memory_thread/utils/logger.py @@ -4,11 +4,8 @@ Provides JSON-formatted logs with context binding for observability. Supports both structlog and stdlib logging for compatibility. -Usage: - from memory_thread.utils.logger import get_logger - - log = get_logger(__name__) - log.info("Processing memory", memory_id=uuid, namespace="default") +By default, logs to 'memory_thread.log' to keep the terminal clean. +Console output is restricted to warnings/errors unless MT_DEBUG is set. """ import logging import sys @@ -16,6 +13,7 @@ from typing import Any, Dict, Optional from contextvars import ContextVar from datetime import datetime +from logging.handlers import RotatingFileHandler # Try to use structlog if available, fallback to json logger try: @@ -24,7 +22,10 @@ except ImportError: STRUCTLOG_AVAILABLE = False -from pythonjsonlogger import jsonlogger +try: + from pythonjsonlogger import jsonlogger +except ImportError: + jsonlogger = None # Context variables for request-scoped data _request_id: ContextVar[Optional[str]] = ContextVar("request_id", default=None) @@ -53,7 +54,7 @@ def clear_context(): _user_id.set(None) -class ContextAwareFormatter(jsonlogger.JsonFormatter): +class ContextAwareFormatter(jsonlogger.JsonFormatter if jsonlogger else logging.Formatter): """JSON formatter that includes context variables.""" def add_fields(self, log_record: Dict[str, Any], record: logging.LogRecord, message_dict: Dict[str, Any]): @@ -72,70 +73,52 @@ def add_fields(self, log_record: Dict[str, Any], record: logging.LogRecord, mess log_record["timestamp"] = datetime.utcnow().isoformat() + "Z" -def _configure_structlog(): - """Configure structlog with appropriate processors.""" - if not STRUCTLOG_AVAILABLE: - return - - structlog.configure( - processors=[ - structlog.contextvars.merge_contextvars, - structlog.processors.add_log_level, - structlog.processors.TimeStamper(fmt="iso", utc=True), - structlog.processors.StackInfoRenderer(), - structlog.processors.format_exc_info, - structlog.processors.JSONRenderer() - ], - wrapper_class=structlog.make_filtering_bound_logger(logging.INFO), - context_class=dict, - logger_factory=structlog.PrintLoggerFactory(), - cache_logger_on_first_use=True - ) - - -# Configure on module load -_configured = False - - -def get_logger(name: str, use_structlog: bool = False): +def get_logger(name: str): """ Get a configured logger instance. - Args: - name: Logger name (typically __name__) - use_structlog: If True and available, return structlog logger - - Returns: - Configured logger with JSON formatting and context awareness + Logs to 'memory_thread.log' (JSON) and Console (Human-readable WARNING+). """ - global _configured + logger = logging.getLogger(name) - if use_structlog and STRUCTLOG_AVAILABLE: - if not _configured: - _configure_structlog() - _configured = True - return structlog.get_logger(name) + # If already configured, return it + if logger.handlers: + return logger - # Standard library logger with JSON formatting - logger = logging.getLogger(name) + logger.setLevel(logging.INFO) + logger.propagate = False + + # 1. File Handler (JSON, Debug info) - Always active + file_handler = RotatingFileHandler("memory_thread.log", maxBytes=5*1024*1024, backupCount=3) + file_handler.setLevel(logging.INFO) - if not logger.handlers: - handler = logging.StreamHandler(sys.stdout) + if jsonlogger: formatter = ContextAwareFormatter( "%(asctime)s %(name)s %(levelname)s %(message)s" ) - handler.setFormatter(formatter) - logger.addHandler(handler) + file_handler.setFormatter(formatter) + else: + file_handler.setFormatter(logging.Formatter( + "%(asctime)s [%(levelname)s] %(name)s: %(message)s" + )) - # Set level from env var or default - level = os.environ.get("LOG_LEVEL", "INFO").upper() - logger.setLevel(getattr(logging, level, logging.INFO)) - logger.propagate = False + logger.addHandler(file_handler) + + # 2. Console Handler (Human-readable, Warnings only) - For user visibility + # If MT_DEBUG is set, show INFO logs to console too + console_level = logging.INFO if os.environ.get("MT_DEBUG") else logging.WARNING + + console_handler = logging.StreamHandler(sys.stderr) + console_handler.setLevel(console_level) + console_handler.setFormatter(logging.Formatter( + "[%(levelname)s] %(message)s" # Simple format for terminal + )) + + logger.addHandler(console_handler) return logger -# Convenience function for operation logging def log_operation( logger, operation: str, @@ -145,13 +128,6 @@ def log_operation( ): """ Log an operation with standardized fields. - - Args: - logger: Logger instance - operation: Operation name (e.g., "remember", "recall") - status: Status (e.g., "started", "completed", "failed") - duration_ms: Optional duration in milliseconds - **kwargs: Additional context """ extra = { "operation": operation, @@ -165,4 +141,3 @@ def log_operation( logger.error(f"{operation} {status}", extra=extra) else: logger.info(f"{operation} {status}", extra=extra) -