#!/usr/bin/env python3 """ TrueRecall v2 - Interactive Install Script Prompts for configuration and sets up the system. Simple inline format with go-back navigation. """ import json import os import sys from pathlib import Path # Configuration state config = { "mode": None, "embedding_model": "mxbai-embed-large", "timer_minutes": 5, "batch_size": 100, "qdrant_url": "http://localhost:6333", "ollama_url": "http://localhost:11434", "curator_model": "qwen3:30b-a3b-instruct", "user_id": "rob", "source_collection": "memories_tr", "target_collection": "gems_tr" } # Track position for go-back history = [] def prompt_inline(question, options, default_key, allow_custom=False): """ Show inline prompt with 0. go back option. options: dict like {"1": ("value", "description"), ...} Returns: (value, go_back_flag) """ print(f"\n{question} [{default_key}]") print(" 0. go back (return to previous question)") for key, (value, desc) in options.items(): marker = " [default]" if key == default_key else "" print(f" {key}. {value:<25} ({desc}){marker}") if allow_custom: print(f" {len(options)+1}. custom (enter your own value)") while True: choice = input(f"\nSelect [0-{len(options) + (1 if allow_custom else 0)} or type name]: ").strip() # Go back if choice == "0": return None, True # Empty = default if choice == "": return options[default_key][0], False # Number selection if choice in options: return options[choice][0], False # Custom if allow_custom and choice == str(len(options) + 1): custom = input("Enter custom value: ").strip() return custom if custom else options[default_key][0], False # Direct name entry for key, (value, desc) in options.items(): if choice.lower() == value.lower(): return value, False print("Invalid choice. Try again.") def prompt_custom(question, default): """Prompt for custom text input with go-back option.""" print(f"\n{question}") print(" 0. go back (return to previous question)") print(f" 1. use default ({default})") print(" 2. custom (enter your own value)") while True: choice = input("\nSelect [0-2]: ").strip() if choice == "0": return None, True if choice == "1" or choice == "": return default, False if choice == "2": custom = input("Enter value: ").strip() return custom if custom else default, False print("Invalid choice. Try again.") def install_full(): """Full installation prompts.""" prompts = [ # (key, question, options, default_key, allow_custom) ("embedding_model", "Embedding model", { "1": ("snowflake-arctic-embed2", "fast, MTEB ~60, high-volume"), "2": ("mxbai-embed-large", "accurate, MTEB 66.5, recommended") }, "2", True), ("timer_minutes", "Timer interval", { "1": ("5", "minutes, fast backlog clearing"), "2": ("30", "minutes, balanced, recommended"), "3": ("60", "minutes, minimal overhead") }, "2", True), ("batch_size", "Batch size", { "1": ("50", "conservative, less memory"), "2": ("100", "balanced, recommended"), "3": ("500", "aggressive, faster backlog") }, "2", True), ("qdrant_url", "Qdrant URL", { "1": ("http://localhost:6333", "localhost hostname"), "2": ("http://127.0.0.1:6333", "localhost IP") }, "1", True), ("ollama_url", "Ollama URL", { "1": ("http://localhost:11434", "localhost hostname"), "2": ("http://127.0.0.1:11434", "localhost IP") }, "1", True), ("curator_model", "Curator LLM", { "1": ("qwen3:4b-instruct", "fast ~10s, basic quality"), "2": ("qwen3:30b-a3b-instruct", "quality ~3s, recommended"), "3": ("qwen3:30b-a3b-instruct-2507-q8_0", "quality, specific version") }, "2", True), ("user_id", "User ID", { "1": ("rob", "default") }, "1", True), ] # Plugin config prompts (full mode only) plugin_prompts = [ ("max_recall_results", "Max gems per turn (injection)", { "1": ("2", "conservative, less context bloat"), "2": ("5", "balanced, recommended"), "3": ("10", "more context, higher recall") }, "2", True), ("min_recall_score", "Minimum similarity score", { "1": ("0.5", "loose matching, more gems"), "2": ("0.7", "balanced, recommended"), "3": ("0.9", "strict matching, only strong matches") }, "2", True), ] idx = 0 while idx < len(prompts): key, question, options, default_key, allow_custom = prompts[idx] if key in ["qdrant_url", "ollama_url", "user_id"] and allow_custom: # These use custom prompt value, go_back = prompt_custom(question, options[default_key][0]) else: value, go_back = prompt_inline(question, options, default_key, allow_custom) if go_back and idx > 0: idx -= 1 continue if go_back and idx == 0: return False # Go back to mode selection config[key] = value idx += 1 # Process plugin prompts idx = 0 while idx < len(plugin_prompts): key, question, options, default_key, allow_custom = plugin_prompts[idx] value, go_back = prompt_inline(question, options, default_key, allow_custom) if go_back: if idx > 0: idx -= 1 continue else: # Go back to main prompts return False config[key] = value idx += 1 return True def install_simple(): """Simple installation prompts (watcher only).""" prompts = [ ("embedding_model", "Embedding model", { "1": ("snowflake-arctic-embed2", "fast, MTEB ~60, high-volume"), "2": ("mxbai-embed-large", "accurate, MTEB 66.5, recommended") }, "1", True), ("qdrant_url", "Qdrant URL", { "1": ("http://localhost:6333", "localhost hostname"), "2": ("http://127.0.0.1:6333", "localhost IP") }, "1", True), ("user_id", "User ID", { "1": ("rob", "default") }, "1", True), ] idx = 0 while idx < len(prompts): key, question, options, default_key, allow_custom = prompts[idx] if allow_custom: value, go_back = prompt_custom(question, options[default_key][0]) else: value, go_back = prompt_inline(question, options, default_key, allow_custom) if go_back and idx > 0: idx -= 1 continue if go_back and idx == 0: return False # Go back to mode selection config[key] = value idx += 1 return True def write_config(): """Write configuration files.""" script_dir = Path(__file__).parent config_path = script_dir / "tr-continuous" / "curator_config.json" # Ensure directory exists config_path.parent.mkdir(parents=True, exist_ok=True) output = { "timer_minutes": int(config["timer_minutes"]), "batch_size": int(config["batch_size"]), "user_id": config["user_id"], "source_collection": config["source_collection"], "target_collection": config["target_collection"], "embedding_model": config["embedding_model"], "curator_model": config["curator_model"], "qdrant_url": config["qdrant_url"], "ollama_url": config["ollama_url"] } # Remove curator settings for simple mode if config["mode"] == "simple": output.pop("timer_minutes", None) output.pop("batch_size", None) output.pop("curator_model", None) output.pop("ollama_url", None) output["target_collection"] = None # No gems in simple mode with open(config_path, 'w') as f: json.dump(output, f, indent=2) # Write plugin config for full mode if config["mode"] == "full": plugin_config = { "collectionName": config["target_collection"], "captureCollection": config["source_collection"], "maxRecallResults": int(config.get("max_recall_results", 5)), "minRecallScore": float(config.get("min_recall_score", 0.7)), "qdrantUrl": config["qdrant_url"] } plugin_path = script_dir / "plugin-config.json" with open(plugin_path, 'w') as f: json.dump(plugin_config, f, indent=2) return config_path def detect_v1(): """Detect if TrueRecall v1 is installed and warn user.""" v1_indicators = [] # Check for v1 folder v1_paths = [ Path.home() / ".openclaw" / "workspace" / ".projects" / "true-recall", Path.home() / ".openclaw" / "workspace" / ".projects" / "true-recall" / "tr-daily", ] for p in v1_paths: if p.exists(): v1_indicators.append(f"Found v1 folder: {p}") # Check for v1 cron jobs cron_file = Path.home() / ".openclaw" / "tr-daily" / "crontab.txt" if cron_file.exists(): v1_indicators.append(f"Found v1 cron file: {cron_file}") # Check for v1 systemd service v1_service_paths = [ Path("/etc/systemd/system/memories-qdrant.service"), Path.home() / ".config" / "systemd" / "user" / "memories-qdrant.service", ] for s in v1_service_paths: if s.exists(): v1_indicators.append(f"Found v1 service: {s}") if not v1_indicators: return False print("\n" + "=" * 60) print("⚠️ TRUE-RECALL V1 DETECTED") print("=" * 60) print("\nTrueRecall v1 is already installed on this system:") for indicator in v1_indicators: print(f" • {indicator}") print("\n⚠️ UPGRADE NOT SUPPORTED") print("-" * 60) print("TrueRecall v2 cannot upgrade from v1 automatically.") print("You MUST remove v1 completely before installing v2.") print("\nTo remove v1:") print(" 1. Stop v1 services: sudo systemctl stop memories-qdrant") print(" 2. Remove v1 cron jobs from crontab") print(" 3. Archive or delete v1 folder: ~/.openclaw/workspace/.projects/true-recall/") print(" 4. v1 data in Qdrant 'memories' collection will remain (separate from v2)") print("\n" + "-" * 60) print("If you continue WITHOUT removing v1:") print(" • Both v1 and v2 may run simultaneously") print(" • Duplicate cron jobs may cause conflicts") print(" • System resource usage will be doubled") print(" • Data integrity is NOT guaranteed") print("=" * 60) while True: choice = input("\nContinue installation at your own risk? [y/N]: ").strip().lower() if choice in ("y", "yes"): print("\n⚠️ Proceeding with installation — v1 conflicts possible.") return True elif choice in ("n", "no", ""): print("\n❌ Installation cancelled.") print("Please remove v1 first, then run this installer again.") return False else: print("Invalid choice. Enter 'y' to continue or 'n' to abort.") def show_summary(): """Display configuration summary.""" print("\n" + "=" * 60) print("Configuration Summary") print("=" * 60) print(f"Mode: {config['mode']}") print(f"Embedding model: {config['embedding_model']}") print(f"Qdrant URL: {config['qdrant_url']}") print(f"User ID: {config['user_id']}") print(f"Source collection: {config['source_collection']}") if config["mode"] == "full": print(f"Timer interval: {config['timer_minutes']} minutes") print(f"Batch size: {config['batch_size']}") print(f"Ollama URL: {config['ollama_url']}") print(f"Curator LLM: {config['curator_model']}") print(f"Target collection: {config['target_collection']}") print(f"\nPlugin settings:") print(f" Max gems per turn: {config.get('max_recall_results', 5)}") print(f" Min similarity score: {config.get('min_recall_score', 0.7)}") else: print("Target collection: (none - simple mode)") print("Plugin injection: (disabled - simple mode)") print("=" * 60) def main(): print("=" * 60) print("TrueRecall v2 - Interactive Installation") print("=" * 60) # Check for v1 if not detect_v1(): sys.exit(1) # Mode selection while True: print("\nSelect installation mode:") print(" 0. exit (cancel installation)") print(" 1. full (watcher + curator + gems)") print(" 2. simple (watcher only - memories)") choice = input("\nSelect [0-2]: ").strip() if choice == "0": print("\nInstallation cancelled.") return if choice == "1": config["mode"] = "full" if install_full(): break elif choice == "2": config["mode"] = "simple" if install_simple(): break else: print("Invalid choice. Try again.") # Show summary show_summary() # Confirm confirm = input("\nApply this configuration? [Y/n]: ").strip().lower() if confirm and confirm not in ("y", "yes"): print("\nInstallation cancelled.") return # Write config config_path = write_config() print(f"\n✅ Configuration written to: {config_path}") # Next steps print("\n" + "=" * 60) print("Next Steps") print("=" * 60) if config["mode"] == "full": cron_expr = f"*/{config['timer_minutes']} * * * *" print(f"1. Add to crontab:") print(f" {cron_expr} cd {config_path.parent} && python3 curator_timer.py >> /var/log/true-recall-timer.log 2>&1") print(f"\n2. Start the watcher:") print(f" sudo systemctl start mem-qdrant-watcher") print(f" sudo systemctl enable mem-qdrant-watcher") else: print(f"1. Start the watcher:") print(f" sudo systemctl start mem-qdrant-watcher") print(f" sudo systemctl enable mem-qdrant-watcher") print(f"\nNote: Simple mode has no curator. Memories are captured only.") print(f"\n3. Check logs:") print(f" tail -f /var/log/true-recall-timer.log") print(f" sudo journalctl -u mem-qdrant-watcher -f") print(f"\n4. Run validation:") print(f" cat checklist.md") print("\n✅ Installation complete!") if __name__ == "__main__": main()