diff --git a/.gitignore b/.gitignore index 5735199..7186383 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,12 @@ /include /bin .ropeproject + +# Virtual environment config (contains personal paths) +pyvenv.cfg + +# Large binaries (PDFs should not be tracked in git) +*.pdf + +# Output directories +output/ diff --git a/Project Proposal - Episode Matcher.pdf b/Project Proposal - Episode Matcher.pdf deleted file mode 100644 index daaf670..0000000 Binary files a/Project Proposal - Episode Matcher.pdf and /dev/null differ diff --git a/The IDIOT Method.pdf b/The IDIOT Method.pdf deleted file mode 100644 index 24fd32f..0000000 Binary files a/The IDIOT Method.pdf and /dev/null differ diff --git a/pyvenv.cfg b/pyvenv.cfg deleted file mode 100644 index e11e79f..0000000 --- a/pyvenv.cfg +++ /dev/null @@ -1,5 +0,0 @@ -home = /opt/homebrew/opt/python@3.13/bin -include-system-site-packages = false -version = 3.13.5 -executable = /opt/homebrew/Cellar/python@3.13/3.13.5/Frameworks/Python.framework/Versions/3.13/bin/python3.13 -command = /opt/homebrew/opt/python@3.13/bin/python3.13 -m venv /Users/user/Projects/Master Mind diff --git a/scripts/mastermind_cli.py b/scripts/mastermind_cli.py index c504f22..d07e3ff 100644 --- a/scripts/mastermind_cli.py +++ b/scripts/mastermind_cli.py @@ -5,23 +5,27 @@ MasterMind CLI This script will: 1. Load the list of questions from `Master Mind/questions.json`. -2. Load the system prompt from a user‑provided file (default: `system_prompt.txt`). +2. Load the system prompt from a user-provided file (default: `system_prompt.txt`). 3. Load the proposal and IDIOT method documents. 4. For each question: - Send a request to a local AI model. - - Store the response in a file inside a project‑named folder. + - Store the response in a file inside a project-named folder. - Log the activity to the console. A very small helper is used to talk to a local model; it can be replaced with a real LLM client (Llama.cpp, Ollama, FastLLM, etc.) by editing the ``fetch_response`` function. Usage: - mastermind_cli.py \ - --project ProjectName \ - --proposal path/to/ProjectProposal.txt \ - --idiot path/to/IDIOTMethod.txt \ - [--prompt-file path/to/system_prompt.txt] \ - [--model-magic "llama3"] # optional flag for the local model name + mastermind_cli.py \\ + --project ProjectName \\ + --proposal path/to/ProjectProposal.txt \\ + --idiot path/to/IDIOTMethod.txt \\ + [--prompt-file path/to/system_prompt.txt] \\ + [--model "llama3"] # optional flag for the local model name + [--ollama-url "http://..."] # optional Ollama endpoint (default: OLLAMA_URL env) + [--questions-file path.json] # optional questions file (default: questions.json) + [--output-dir path] # optional output directory + [--api-key "secret"] # optional API key for Ollama auth """ from __future__ import annotations @@ -29,19 +33,50 @@ from __future__ import annotations import argparse import json import logging +import os import pathlib +import re import sys -import urllib.request from typing import List logging.basicConfig(level=logging.INFO, format="%(message)s") +# Sliding window size for accumulated context (issue #8) +MAX_CONTEXT_WINDOW = 5 + + +def validate_project_name(name: str) -> str: + """Validate project name contains only safe characters. Prevents path traversal.""" + if not re.match(r"^[a-zA-Z0-9_-]+$", name): + logging.error( + "Invalid project name '%s'. Only alphanumeric, hyphens, and underscores allowed.", + name, + ) + sys.exit(1) + return name + + +def validate_no_traversal(path: pathlib.Path, label: str) -> pathlib.Path: + """Ensure resolved path does not escape its intended base directory.""" + resolved = path.resolve() + base = pathlib.Path(".").resolve() + try: + resolved.relative_to(base) + except ValueError: + logging.error( + "Invalid %s '%s': path escapes the allowed base directory.", + label, + path, + ) + sys.exit(1) + return resolved + def read_file(path: pathlib.Path) -> str: """Return the contents of a text file.""" try: return path.read_text(encoding="utf-8") - except Exception as exc: # pragma: no cover + except Exception as exc: logging.error("Could not read %s: %s", path, exc) sys.exit(1) @@ -51,7 +86,7 @@ def load_questions(json_path: pathlib.Path) -> List[dict]: text = read_file(json_path) try: return json.loads(text) - except json.JSONDecodeError as exc: # pragma: no cover + except json.JSONDecodeError as exc: logging.error("Invalid JSON in %s: %s", json_path, exc) sys.exit(1) @@ -61,6 +96,9 @@ def fetch_response( user_prompt: str, context: List[str], model: str = "gpt-oss:20b", + ollama_url: str = "http://localhost:11434/api/generate", + api_key: str | None = None, + timeout: int = 120, ) -> str: """ Send a request to a local AI model and return the response. @@ -71,40 +109,50 @@ def fetch_response( Parameters ---------- system_prompt : str - The system‑level instruction to the AI. + The system-level instruction to the AI. user_prompt : str The question or user message. context : List[str] Optional additional context to prepend to the request. + model : str + The model name to use. + ollama_url : str + The Ollama API endpoint URL. + api_key : str | None + Optional API key for authentication. + timeout : int + Request timeout in seconds. Returns ------- str - The AI’s reply. + The AI's reply. """ - # Combine system prompt, context, and user prompt. prompt_parts = [system_prompt] prompt_parts.extend(f"{ctx}" for ctx in context) prompt_parts.append(f"Question: {user_prompt}") prompt = "\n\n".join(prompt_parts) + "\n" - # Call Ollama API - url = "http://192.168.8.112:11434/api/generate" payload = json.dumps({ "model": model, "prompt": prompt, - "stream": False + "stream": False, }).encode("utf-8") headers = {"Content-Type": "application/json"} - req = urllib.request.Request(url, data=payload, headers=headers, method="POST") + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + req = __import__("urllib.request").request.Request( + ollama_url, data=payload, headers=headers, method="POST" + ) try: - with urllib.request.urlopen(req) as resp: + with __import__("urllib.request").request.urlopen(req, timeout=timeout) as resp: data = resp.read().decode("utf-8") result = json.loads(data) return result.get("response", "").strip() - except Exception as exc: # pragma: no cover + except Exception as exc: logging.error("Ollama request failed: %s", exc) - return f"Simulated response to: {user_prompt}" + sys.exit(1) def main() -> None: @@ -147,21 +195,60 @@ def main() -> None: default="", help="Custom output directory for the project folder (default: current dir).", ) + parser.add_argument( + "--ollama-url", + default=None, + help="Ollama API endpoint URL (default: $OLLAMA_URL or http://localhost:11434/api/generate).", + ) + parser.add_argument( + "--api-key", + default=None, + help="API key for Ollama authentication (default: $OLLAMA_API_KEY).", + ) + parser.add_argument( + "--questions-file", + default=None, + help="Path to questions JSON file (default: questions.json next to script).", + ) + parser.add_argument( + "--timeout", + type=int, + default=120, + help="Request timeout in seconds for Ollama calls (default: 120).", + ) args = parser.parse_args() + # Resolve Ollama URL: CLI arg > env var > default + ollama_url = args.ollama_url or os.environ.get( + "OLLAMA_URL", "http://localhost:11434/api/generate" + ) + api_key = args.api_key or os.environ.get("OLLAMA_API_KEY") + + # Warn if using HTTP (cleartext) + if ollama_url.startswith("http://"): + logging.warning( + "Using unencrypted HTTP for Ollama endpoint. " + "Sensitive project data will be transmitted in cleartext. " + "Use https:// or ensure network isolation." + ) + + # Validate project name against path traversal + validate_project_name(args.project) + base_dir = pathlib.Path.cwd() - # Resolve project output directory + # Resolve project output directory with traversal protection if args.output_dir: out_base = pathlib.Path(args.output_dir).expanduser().resolve() + validate_no_traversal(out_base, "--output-dir") else: out_base = base_dir project_dir = out_base / args.project try: project_dir.mkdir(parents=True, exist_ok=True) - except Exception as exc: # pragma: no cover + except Exception as exc: logging.error("Could not create project directory %s: %s", project_dir, exc) sys.exit(1) @@ -174,7 +261,12 @@ def main() -> None: proposal_text = read_file(proposal_path) idiot_text = read_file(idiot_path) - questions_path = base_dir.parent / "questions.json" + # Resolve questions file: CLI arg > script-relative > project root + if args.questions_file: + questions_path = pathlib.Path(args.questions_file) + else: + script_dir = pathlib.Path(__file__).resolve().parent + questions_path = script_dir.parent / "questions.json" questions = load_questions(questions_path) # Merge context into system prompt for easier reuse @@ -196,8 +288,8 @@ def main() -> None: phase_qs = phase.get("questions", []) logging.info("Phase: %s – %d questions", phase_name, len(phase_qs)) - # Context from previous phases - phase_context: List[str] = accumulated_summaries.copy() + # Sliding window: only keep last N summaries to bound context growth + phase_context: List[str] = accumulated_summaries[-MAX_CONTEXT_WINDOW:] for q in phase_qs: question_text = q.get("text", "") @@ -206,12 +298,18 @@ def main() -> None: logging.info("Q%d: %s", counter, question_text) response = fetch_response( - full_system_prompt, question_text, phase_context, args.model + full_system_prompt, + question_text, + phase_context, + args.model, + ollama_url, + api_key, + args.timeout, ) output_file = project_dir / f"question_{counter:03d}.txt" try: output_file.write_text(response, encoding="utf-8") - except Exception as exc: # pragma: no cover + except Exception as exc: logging.error("Could not write %s: %s", output_file, exc) continue logging.info("Saved response to %s", output_file) @@ -224,21 +322,28 @@ def main() -> None: project_dir / f"{phase_name.replace(' ', '_').lower()}_summary.txt" ) try: - summary = fetch_response("Make a clear and concise summary that's no more than 500 tokens on the following input", + summary = fetch_response( + "Make a clear and concise summary that's no more than 500 tokens on the following input", "\n\n".join(accumulated_summaries), - []) - - phase_summary_file.write_text( - summary, encoding="utf-8" + [], + args.model, + ollama_url, + api_key, + args.timeout, ) - logging.info("Saved per‑phase summary to %s", phase_summary_file) - except Exception as exc: # pragma: no cover + + phase_summary_file.write_text(summary, encoding="utf-8") + logging.info("Saved per-phase summary to %s", phase_summary_file) + except SystemExit: + raise + except Exception as exc: logging.error("Could not write %s: %s", phase_summary_file, exc) - experiment_results.append({ - "phase": phase_name, - "summary_file": str(phase_summary_file), - }) + # Record result for every phase (was incorrectly inside except block) + experiment_results.append({ + "phase": phase_name, + "summary_file": str(phase_summary_file), + }) logging.info("All done! Total responses: %d", counter - 1)