diff --git a/docs/scout.md b/docs/scout.md new file mode 100644 index 0000000..c91fbcf --- /dev/null +++ b/docs/scout.md @@ -0,0 +1,68 @@ +# Solution Scout v0 + +Solution Scout is a Hermes plugin that watches safe turn summaries, searches the M2 +Marketplace catalog, and shows a non-blocking proposal when a published Solution appears to +cover the current task. + +## Install + +```bash +./scout/install.sh +``` + +The installer is an idempotent file drop to: + +- `~/.hermes/plugins/m2-solution-scout/plugin.yaml` +- `~/.hermes/plugins/m2-solution-scout/__init__.py` +- `~/.config/m2-market/pull-policy.toml` only when that file does not already exist + +It does not enable Scout. To opt in, edit `~/.config/m2-market/pull-policy.toml` and set: + +```toml +[scout] +enabled = true +tenant_id = "machine-machine" +desktop_id = "chris-m2o" +operator_id = "op_chris" +``` + +## Guardrails + +Scout fails closed. Missing config, `enabled=false`, or a missing `tenant_id` makes every hook +a no-op. `raw_keystrokes=true` or `raw_client_data=true` refuses session start. The plugin +summarizes and redacts on-box before memory-api egress, searches only `agent_id = +"market:catalog"`, filters hits by `tenant_visibility` containing the desktop tenant or `"*"`, +rate-limits proposals, and never calls `m2-market install`. + +## Event Flow + +On `on_session_start`, the plugin loads `pull-policy.toml`. On `post_llm_call`, it gates on +policy, rate caps, and cooldowns, then starts a background thread so slow catalog queries do +not block the operator turn. The worker builds an `m2.scout.intent_summary.v1`, posts +`/memory/search` with `agent_id=market:catalog`, applies client-side tenant filtering and +threshold checks, builds an `m2.scout.proposal.v1`, shows `notify-send` with a Store deep +link, and falls back to the Herdr socket toast when desktop notification delivery is +unavailable. + +The deep link format is: + +```text +m2store://listing/{listing_id}?proposal_id={proposal_id}&source=scout +``` + +## Telemetry Paths + +Local state lives at: + +```text +~/.local/share/m2-market/scout/state.json +``` + +Proposal telemetry is append-only JSONL: + +```text +~/.local/share/m2-market/scout/outbox/YYYY-MM-DD.jsonl +``` + +The v0 plugin records `proposal_shown` locally for later batching to `market:evidence`. +Accept, dismiss, debit, grant, and install remain owned by M2 Store and `m2-market`. diff --git a/scout/install.sh b/scout/install.sh new file mode 100755 index 0000000..f8933b8 --- /dev/null +++ b/scout/install.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PLUGIN_SRC="${SRC_DIR}/plugin/m2-solution-scout" +PLUGIN_DST="${HOME}/.hermes/plugins/m2-solution-scout" +CONFIG_DIR="${HOME}/.config/m2-market" +CONFIG_PATH="${CONFIG_DIR}/pull-policy.toml" + +mkdir -p "${PLUGIN_DST}" "${CONFIG_DIR}" +install -m 0644 "${PLUGIN_SRC}/plugin.yaml" "${PLUGIN_DST}/plugin.yaml" +install -m 0644 "${PLUGIN_SRC}/__init__.py" "${PLUGIN_DST}/__init__.py" + +if [[ ! -f "${CONFIG_PATH}" ]]; then + install -m 0600 "${SRC_DIR}/pull-policy.example.toml" "${CONFIG_PATH}" + echo "Created disabled config scaffold: ${CONFIG_PATH}" +else + echo "Config already exists, left unchanged: ${CONFIG_PATH}" +fi + +echo "Installed Hermes plugin: ${PLUGIN_DST}" +echo "Opt in by editing ${CONFIG_PATH}: set scout.enabled=true and scout.tenant_id to this desktop tenant." diff --git a/scout/plugin/m2-solution-scout/__init__.py b/scout/plugin/m2-solution-scout/__init__.py new file mode 100644 index 0000000..93fd6c6 --- /dev/null +++ b/scout/plugin/m2-solution-scout/__init__.py @@ -0,0 +1,537 @@ +"""Hermes plugin for M2 Solution Scout v0. + +The plugin is intentionally fail-closed: no policy opt-in means no hooks do +anything beyond loading local config, and raw-source deny keys refuse startup. +""" + +from __future__ import annotations + +import datetime as _dt +import hashlib +import json +import os +import random +import re +import shutil +import socket +import subprocess +import threading +import time +from pathlib import Path +from typing import Any +from urllib.parse import quote + +POLICY_PATH = Path("~/.config/m2-market/pull-policy.toml").expanduser() +STATE_DIR = Path("~/.local/share/m2-market/scout").expanduser() +STATE_PATH = STATE_DIR / "state.json" +OUTBOX_DIR = STATE_DIR / "outbox" + +_SECRET_PATTERNS = [ + re.compile(r"sk-[A-Za-z0-9_-]{20,}"), + re.compile(r"(?i)\b(api[_ -]?key|secret|token)\s*[:=]\s*['\"]?[^'\"\s]{8,}"), +] +_EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b") +_WORD_RE = re.compile(r"[A-Za-z][A-Za-z0-9_-]{2,}") + + +class PolicyError(RuntimeError): + """Policy explicitly requests an unsafe mode.""" + + +def _utcnow() -> _dt.datetime: + return _dt.datetime.now(_dt.timezone.utc) + + +def _iso(ts: _dt.datetime | None = None) -> str: + return (ts or _utcnow()).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _parse_scalar(value: str) -> Any: + value = value.strip() + if not value or value.startswith("#"): + return "" + if "#" in value and not value.lstrip().startswith(("'", '"')): + value = value.split("#", 1)[0].strip() + if value in {"true", "false"}: + return value == "true" + if (value.startswith('"') and value.endswith('"')) or ( + value.startswith("'") and value.endswith("'") + ): + return value[1:-1] + if value.startswith("[") and value.endswith("]"): + inner = value[1:-1].strip() + if not inner: + return [] + return [_parse_scalar(part.strip()) for part in inner.split(",")] + try: + if "." in value: + return float(value) + return int(value) + except ValueError: + return value + + +def parse_toml(text: str) -> dict[str, Any]: + """Tiny TOML subset parser, with optional tomli avoided for Python 3.10 hosts.""" + + data: dict[str, Any] = {} + current = data + for raw in text.splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + if line.startswith("[") and line.endswith("]"): + current = data + for part in line[1:-1].split("."): + current = current.setdefault(part.strip(), {}) + continue + if "=" not in line: + continue + key, value = line.split("=", 1) + current[key.strip()] = _parse_scalar(value) + return data + + +def _read_toml(path: Path) -> dict[str, Any]: + try: + import tomli # type: ignore + except Exception: + tomli = None + + if not path.exists(): + return {} + raw = path.read_bytes() + if tomli is not None: + return tomli.loads(raw.decode("utf-8")) + return parse_toml(raw.decode("utf-8")) + + +def _section(data: dict[str, Any], *names: str) -> dict[str, Any]: + node: Any = data + for name in names: + if not isinstance(node, dict): + return {} + node = node.get(name, {}) + return node if isinstance(node, dict) else {} + + +def load_policy(path: Path = POLICY_PATH) -> dict[str, Any]: + raw = _read_toml(path) + scout = _section(raw, "scout") + sources = _section(raw, "scout", "sources") + summary = _section(raw, "scout", "summary") + catalog = _section(raw, "scout", "catalog") + store = _section(raw, "scout", "store") + telemetry = _section(raw, "scout", "telemetry") + + enabled = bool(scout.get("enabled", False)) + tenant_id = str(scout.get("tenant_id", "") or "").strip() + if sources.get("raw_keystrokes") is True or sources.get("raw_client_data") is True: + raise PolicyError("Solution Scout refuses raw_keystrokes/raw_client_data policy") + if not enabled or not tenant_id: + return {"enabled": False, "reason": "disabled_or_missing_tenant"} + + return { + "enabled": True, + "mode": scout.get("mode", "suggest"), + "desktop_id": str(scout.get("desktop_id", "unknown-desktop")), + "operator_id": str(scout.get("operator_id", "unknown-operator")), + "tenant_id": tenant_id, + "min_score": float(scout.get("min_score", 0.78)), + "min_coverage": float(scout.get("min_coverage", 0.55)), + "max_proposals_per_day": int(scout.get("max_proposals_per_day", 5)), + "max_proposals_per_session": int(scout.get("max_proposals_per_session", 1)), + "dismiss_cooldown_days": int(scout.get("dismiss_cooldown_days", 14)), + "proposal_cooldown_minutes": int(scout.get("proposal_cooldown_minutes", 45)), + "summary": { + "max_source_chars": int(summary.get("max_source_chars", 12000)), + "max_summary_chars": int(summary.get("max_summary_chars", 900)), + "redact_secrets": bool(summary.get("redact_secrets", True)), + "redact_client_identifiers": bool(summary.get("redact_client_identifiers", True)), + "fail_closed_on_secret": bool(summary.get("fail_closed_on_secret", True)), + }, + "catalog": { + "memory_api_url": str(catalog.get("memory_api_url", "https://memory.machinemachine.ai")), + "agent_id": str(catalog.get("agent_id", "market:catalog")), + "limit": int(catalog.get("limit", 5)), + "routing_strategy": str(catalog.get("routing_strategy", "standard")), + }, + "store": { + "deeplink_scheme": str(store.get("deeplink_scheme", "m2store")), + "fallback_command": str(store.get("fallback_command", "m2-market show --json")), + }, + "telemetry": {"enabled": bool(telemetry.get("enabled", True))}, + } + + +def redact(text: str, *, redact_client_identifiers: bool = True) -> tuple[str, dict[str, bool]]: + found_secret = False + redacted = text + for pattern in _SECRET_PATTERNS: + redacted, count = pattern.subn("[REDACTED_SECRET]", redacted) + found_secret = found_secret or count > 0 + found_client = False + if redact_client_identifiers: + redacted, count = _EMAIL_RE.subn("[REDACTED_EMAIL]", redacted) + found_client = count > 0 + return redacted, { + "secrets_redacted": found_secret, + "client_identifiers_redacted": found_client, + "failed_closed": False, + } + + +def summarize_turn(user_message: str, assistant_response: str, policy: dict[str, Any]) -> dict[str, Any]: + summary_policy = policy["summary"] + source = (user_message + "\n" + assistant_response)[: summary_policy["max_source_chars"]] + clean, scrub = redact( + source, + redact_client_identifiers=summary_policy.get("redact_client_identifiers", True), + ) + sentences = re.split(r"(?<=[.!?])\s+", " ".join(clean.split())) + summary = " ".join(sentences[:3]).strip() + if len(summary) > summary_policy["max_summary_chars"]: + summary = summary[: summary_policy["max_summary_chars"]].rsplit(" ", 1)[0].strip() + terms = [] + for word in _WORD_RE.findall(summary.lower()): + if word in {"the", "and", "with", "this", "that", "from", "have", "will", "into"}: + continue + if word not in terms: + terms.append(word) + if len(terms) >= 12: + break + intent_hash = "sha256:" + hashlib.sha256(summary.encode("utf-8")).hexdigest() + return { + "schema_version": "m2.scout.intent_summary.v1", + "summary_id": _id("sum", policy), + "observation_ids": [], + "desktop_id": policy["desktop_id"], + "operator_id": policy["operator_id"], + "tenant_id": policy["tenant_id"], + "session_id": "", + "summary": summary, + "intent_terms": terms, + "negative_terms": [], + "scrub_status": scrub, + "intent_hash": intent_hash, + "created_at": _iso(), + } + + +def _id(prefix: str, policy: dict[str, Any]) -> str: + stamp = _utcnow().strftime("%Y%m%d") + desktop = re.sub(r"[^A-Za-z0-9]+", "", policy.get("desktop_id", "desktop"))[:12] or "desktop" + return f"{prefix}_{stamp}_{desktop}_{random.randrange(36**4):04x}" + + +def _load_state(path: Path = STATE_PATH) -> dict[str, Any]: + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {"daily": {}, "sessions": {}, "dismissed": {}, "last_proposal_at": None} + + +def _save_state(state: dict[str, Any], path: Path = STATE_PATH) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".tmp") + tmp.write_text(json.dumps(state, sort_keys=True, indent=2) + "\n", encoding="utf-8") + tmp.replace(path) + + +def gate(policy: dict[str, Any], state: dict[str, Any], *, session_id: str = "") -> tuple[bool, str | None]: + if not policy.get("enabled"): + return False, "disabled" + today = _utcnow().date().isoformat() + daily_count = int(state.get("daily", {}).get(today, 0)) + if daily_count >= policy["max_proposals_per_day"]: + return False, "rate_capped" + if session_id: + session_count = int(state.get("sessions", {}).get(session_id, 0)) + if session_count >= policy["max_proposals_per_session"]: + return False, "session_capped" + last = state.get("last_proposal_at") + if last: + try: + last_ts = _dt.datetime.fromisoformat(str(last).replace("Z", "+00:00")) + cooldown = _dt.timedelta(minutes=policy["proposal_cooldown_minutes"]) + if _utcnow() - last_ts < cooldown: + return False, "proposal_cooldown" + except ValueError: + pass + return True, None + + +def _memory_api_key() -> str: + if os.environ.get("M2_MEMORY_API_KEY"): + return os.environ["M2_MEMORY_API_KEY"] + config = _read_toml(Path("~/.m2-market/config.toml").expanduser()) + memory = _section(config, "memory") + for key in ("api_key", "memory_api_key", "token"): + value = memory.get(key) or config.get(key) + if value: + return str(value) + return "" + + +def _visible(listing: dict[str, Any], tenant_id: str) -> bool: + visibility = listing.get("tenant_visibility") or [] + status = listing.get("status", "published") + return status == "published" and ("*" in visibility or tenant_id in visibility) + + +def search_catalog(summary: dict[str, Any], policy: dict[str, Any]) -> list[dict[str, Any]]: + import httpx + + catalog = policy["catalog"] + headers = {} + api_key = _memory_api_key() + if api_key: + headers["X-API-Key"] = api_key + with httpx.Client(base_url=catalog["memory_api_url"], headers=headers, timeout=10.0) as client: + response = client.post( + "/memory/search", + json={ + "query": summary["summary"], + "agent_id": catalog["agent_id"], + "routing_strategy": catalog["routing_strategy"], + "limit": max(catalog["limit"] * 3, catalog["limit"]), + }, + ) + response.raise_for_status() + items = [] + for result in response.json().get("results", []): + meta = result.get("metadata") or {} + listing = meta.get("listing") or {} + listing_id = meta.get("listing_id") or listing.get("listing_id") or listing.get("id") + if not listing_id or not _visible(listing, policy["tenant_id"]): + continue + score = float(result.get("score") or 0.0) + coverage = _estimate_coverage(summary, listing) + if score < policy["min_score"] or coverage < policy["min_coverage"]: + continue + items.append( + { + "listing_id": listing_id, + "solution_id": listing.get("solution_id", ""), + "score": score, + "coverage": coverage, + "listing": listing, + } + ) + if len(items) >= catalog["limit"]: + break + return items + + +def _estimate_coverage(summary: dict[str, Any], listing: dict[str, Any]) -> float: + terms = set(summary.get("intent_terms") or []) + haystack = " ".join( + str(listing.get(k, "")) for k in ("name", "summary", "description", "category") + ).lower() + keywords = listing.get("keywords") or [] + if isinstance(keywords, list): + haystack += " " + " ".join(str(k).lower() for k in keywords) + if not terms: + return 0.0 + hits = sum(1 for term in terms if term in haystack) + return round(min(1.0, hits / max(3, len(terms))), 2) + + +def build_proposal(match: dict[str, Any], summary: dict[str, Any], policy: dict[str, Any]) -> dict[str, Any]: + listing = match["listing"] + proposal_id = _id("prp", policy) + match_id = _id("mat", policy) + listing_id = match["listing_id"] + deeplink = ( + f"{policy['store']['deeplink_scheme']}://listing/{quote(listing_id)}" + f"?proposal_id={quote(proposal_id)}&source=scout" + ) + now = _utcnow() + return { + "schema_version": "m2.scout.proposal.v1", + "proposal_id": proposal_id, + "match_id": match_id, + "desktop_id": policy["desktop_id"], + "operator_id": policy["operator_id"], + "tenant_id": policy["tenant_id"], + "listing": { + "listing_id": listing_id, + "name": listing.get("name", listing_id), + "summary": listing.get("summary", ""), + "price": listing.get("price", {}), + "stats": listing.get("stats", {}), + }, + "confidence": match["score"], + "coverage": match["coverage"], + "deeplink": deeplink, + "shown_at": _iso(now), + "expires_at": _iso(now + _dt.timedelta(minutes=policy["proposal_cooldown_minutes"])), + "_telemetry": { + "session_id": summary.get("session_id", ""), + "intent_hash": summary["intent_hash"], + "solution_id": match.get("solution_id", ""), + }, + } + + +def write_proposal_shown(proposal: dict[str, Any], outbox_dir: Path = OUTBOX_DIR) -> dict[str, Any]: + telemetry = proposal.pop("_telemetry", {}) + event = { + "schema_version": "m2.scout.telemetry.v1", + "event_id": _id("evt", proposal), + "event_type": "proposal_shown", + "proposal_id": proposal["proposal_id"], + "listing_id": proposal["listing"]["listing_id"], + "solution_id": telemetry.get("solution_id", ""), + "operator_id": proposal["operator_id"], + "desktop_id": proposal["desktop_id"], + "tenant_id": proposal["tenant_id"], + "session_id": telemetry.get("session_id", ""), + "intent_hash": telemetry.get("intent_hash", ""), + "score": proposal["confidence"], + "coverage": proposal["coverage"], + "reason": None, + "created_at": proposal["shown_at"], + "proposal": proposal, + } + outbox_dir.mkdir(parents=True, exist_ok=True) + path = outbox_dir / f"{_utcnow().date().isoformat()}.jsonl" + with path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(event, sort_keys=True) + "\n") + return event + + +def notify(proposal: dict[str, Any]) -> None: + listing = proposal["listing"] + price = listing.get("price") or {} + amount = price.get("amount", "?") + currency = price.get("currency", "m2cr") + body = ( + f"This looks like {listing['name']}: ~{int(proposal['coverage'] * 100)}% coverage, " + f"{amount} {currency}. Open in M2 Store?" + ) + if shutil.which("notify-send"): + try: + subprocess.Popen( + [ + "notify-send", + "M2 Solution Scout", + body, + f"--action=open=Open", + f"--action=dismiss=Dismiss", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return + except Exception: + pass + _herdr_toast(body, proposal["deeplink"]) + + +def _herdr_toast(message: str, deeplink: str) -> None: + socket_path = os.environ.get("HERDR_SOCKET_PATH", "").strip() + pane_id = os.environ.get("HERDR_PANE_ID", "").strip() + if not socket_path or not pane_id: + return + request = { + "id": f"m2-solution-scout:{int(time.time() * 1000)}", + "method": "pane.toast", + "params": { + "pane_id": pane_id, + "source": "m2-solution-scout", + "title": "M2 Solution Scout", + "message": message, + "deeplink": deeplink, + }, + } + try: + client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + client.settimeout(0.5) + client.connect(socket_path) + client.sendall((json.dumps(request) + "\n").encode("utf-8")) + client.close() + except Exception: + pass + + +def _bump_state( + state: dict[str, Any], policy: dict[str, Any], session_id: str, proposal: dict[str, Any] +) -> None: + today = _utcnow().date().isoformat() + state.setdefault("daily", {})[today] = int(state.setdefault("daily", {}).get(today, 0)) + 1 + if session_id: + state.setdefault("sessions", {})[session_id] = int( + state.setdefault("sessions", {}).get(session_id, 0) + ) + 1 + state["last_proposal_at"] = _iso() + state["last_proposal"] = { + "proposal_id": proposal["proposal_id"], + "listing_id": proposal["listing"]["listing_id"], + "cooldown_until": proposal["expires_at"], + } + state.setdefault("proposals", []).append(state["last_proposal"]) + _save_state(state) + + +def _extract_turn(kwargs: dict[str, Any]) -> tuple[str, str, str]: + user_message = str(kwargs.get("user_message") or kwargs.get("prompt") or "") + assistant_response = str(kwargs.get("assistant_response") or kwargs.get("response") or "") + if not user_message: + messages = kwargs.get("messages") + if isinstance(messages, list): + user_message = "\n".join( + str(m.get("content", "")) for m in messages if isinstance(m, dict) and m.get("role") == "user" + ) + session_id = str(kwargs.get("session_id") or "") + return user_message, assistant_response, session_id + + +class ScoutRuntime: + def __init__(self) -> None: + self.policy: dict[str, Any] = {"enabled": False} + self._lock = threading.Lock() + + def on_session_start(self, **kwargs: Any) -> None: + del kwargs + self.policy = load_policy() + + def post_llm_call(self, **kwargs: Any) -> None: + user_message, assistant_response, session_id = _extract_turn(kwargs) + if not user_message and not assistant_response: + return + state = _load_state() + ok, _reason = gate(self.policy, state, session_id=session_id) + if not ok: + return + worker = threading.Thread( + target=self._process, + args=(user_message, assistant_response, session_id, state), + daemon=True, + ) + worker.start() + + def _process( + self, user_message: str, assistant_response: str, session_id: str, state: dict[str, Any] + ) -> None: + with self._lock: + try: + summary = summarize_turn(user_message, assistant_response, self.policy) + summary["session_id"] = session_id + matches = search_catalog(summary, self.policy) + if not matches: + return + proposal = build_proposal(matches[0], summary, self.policy) + write_proposal_shown(proposal) + notify(proposal) + _bump_state(state, self.policy, session_id, proposal) + except Exception: + return + + +_RUNTIME = ScoutRuntime() + + +def register(ctx: Any) -> None: + ctx.register_hook("on_session_start", _RUNTIME.on_session_start) + ctx.register_hook("post_llm_call", _RUNTIME.post_llm_call) diff --git a/scout/plugin/m2-solution-scout/plugin.yaml b/scout/plugin/m2-solution-scout/plugin.yaml new file mode 100644 index 0000000..c420b86 --- /dev/null +++ b/scout/plugin/m2-solution-scout/plugin.yaml @@ -0,0 +1,3 @@ +name: m2-solution-scout +version: "0.1" +description: Solution Scout intent watch diff --git a/scout/pull-policy.example.toml b/scout/pull-policy.example.toml new file mode 100644 index 0000000..c58f4ba --- /dev/null +++ b/scout/pull-policy.example.toml @@ -0,0 +1,42 @@ +[scout] +enabled = false +mode = "suggest" +desktop_id = "chris-m2o" +operator_id = "op_chris" +tenant_id = "" +min_score = 0.78 +min_coverage = 0.55 +poll_interval_seconds = 90 +max_proposals_per_day = 5 +max_proposals_per_session = 1 +dismiss_cooldown_days = 14 +proposal_cooldown_minutes = 45 + +[scout.sources] +herdr_runs = true +session_summaries = true +window_titles = false +raw_keystrokes = false +raw_client_data = false + +[scout.summary] +max_source_chars = 12000 +max_summary_chars = 900 +redact_secrets = true +redact_client_identifiers = true +fail_closed_on_secret = true + +[scout.catalog] +memory_api_url = "https://memory.machinemachine.ai" +agent_id = "market:catalog" +limit = 5 +routing_strategy = "standard" + +[scout.store] +deeplink_scheme = "m2store" +fallback_command = "m2-market show --json" + +[scout.telemetry] +enabled = true +agent_id = "market:evidence" +batch_seconds = 60 diff --git a/scout/tests/test_scout.py b/scout/tests/test_scout.py new file mode 100644 index 0000000..aa54089 --- /dev/null +++ b/scout/tests/test_scout.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path + +import httpx +import pytest + + +PLUGIN = Path(__file__).resolve().parents[1] / "plugin" / "m2-solution-scout" / "__init__.py" + + +@pytest.fixture() +def scout(monkeypatch, tmp_path): + spec = importlib.util.spec_from_file_location("m2_solution_scout_test", PLUGIN) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + assert spec.loader is not None + spec.loader.exec_module(module) + monkeypatch.setattr(module, "STATE_PATH", tmp_path / "state.json") + monkeypatch.setattr(module, "STATE_DIR", tmp_path) + monkeypatch.setattr(module, "OUTBOX_DIR", tmp_path / "outbox") + return module + + +def policy(scout): + return { + "enabled": True, + "desktop_id": "chris-m2o", + "operator_id": "op_chris", + "tenant_id": "machine-machine", + "min_score": 0.78, + "min_coverage": 0.2, + "max_proposals_per_day": 5, + "max_proposals_per_session": 1, + "proposal_cooldown_minutes": 45, + "summary": { + "max_source_chars": 12000, + "max_summary_chars": 120, + "redact_client_identifiers": True, + }, + "catalog": { + "memory_api_url": "https://memory.test", + "agent_id": "market:catalog", + "limit": 5, + "routing_strategy": "standard", + }, + "store": {"deeplink_scheme": "m2store"}, + } + + +def test_gate_disabled_opted_out_rate_capped_and_cooldown_noop(scout): + enabled = policy(scout) + assert scout.gate({"enabled": False}, {}, session_id="s1") == (False, "disabled") + assert scout.load_policy(Path("/no/such/file")) == { + "enabled": False, + "reason": "disabled_or_missing_tenant", + } + today = scout._utcnow().date().isoformat() + assert scout.gate( + enabled, + {"daily": {today: 5}, "sessions": {}, "last_proposal_at": None}, + session_id="s1", + ) == (False, "rate_capped") + assert scout.gate( + enabled, + {"daily": {}, "sessions": {"s1": 1}, "last_proposal_at": None}, + session_id="s1", + ) == (False, "session_capped") + assert scout.gate( + enabled, + {"daily": {}, "sessions": {}, "last_proposal_at": scout._iso()}, + session_id="s1", + ) == (False, "proposal_cooldown") + + +def test_policy_refuses_raw_sources(scout, tmp_path): + path = tmp_path / "pull-policy.toml" + path.write_text( + """ +[scout] +enabled = true +tenant_id = "machine-machine" + +[scout.sources] +raw_keystrokes = true +raw_client_data = false +""", + encoding="utf-8", + ) + with pytest.raises(scout.PolicyError): + scout.load_policy(path) + + +def test_redaction_and_summary_are_bounded(scout): + text = ( + "Use sk-abcdefghijklmnopqrstuvwxyz and API_KEY=supersecretvalue for jane@example.com. " + "Build a competitor research report with sources and PDF output." + ) + summary = scout.summarize_turn(text, "Prepare the report.", policy(scout)) + assert "sk-" not in summary["summary"] + assert "supersecretvalue" not in summary["summary"] + assert "jane@example.com" not in summary["summary"] + assert "[REDACTED_SECRET]" in summary["summary"] + assert len(summary["summary"]) <= 120 + assert summary["schema_version"] == "m2.scout.intent_summary.v1" + + +def test_proposal_outbox_record_shape_matches_schema(scout, tmp_path): + p = policy(scout) + summary = scout.summarize_turn("competitor research report pdf", "", p) + match = { + "listing_id": "lst_competitor-scan", + "solution_id": "sol_competitor-scan", + "score": 0.84, + "coverage": 0.72, + "listing": { + "name": "Competitor Scan", + "summary": "Research report package.", + "price": {"amount": 40, "currency": "m2cr", "model": "fixed"}, + "stats": {"installs": 12, "rating": 4.6}, + }, + } + proposal = scout.build_proposal(match, summary, p) + event = scout.write_proposal_shown(proposal, tmp_path / "outbox") + assert event["event_type"] == "proposal_shown" + assert event["proposal"]["schema_version"] == "m2.scout.proposal.v1" + assert event["proposal"]["listing"]["listing_id"] == "lst_competitor-scan" + assert event["proposal"]["deeplink"].startswith("m2store://listing/lst_competitor-scan") + lines = list((tmp_path / "outbox").glob("*.jsonl"))[0].read_text(encoding="utf-8").splitlines() + assert json.loads(lines[0])["proposal_id"] == event["proposal_id"] + + +def test_mocked_catalog_search_filters_visibility_and_posts_live_shape(scout, monkeypatch): + requests = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(json.loads(request.content.decode("utf-8"))) + return httpx.Response( + 200, + json={ + "results": [ + { + "score": 0.91, + "metadata": { + "listing_id": "lst_competitor-scan", + "listing": { + "name": "Competitor Scan", + "summary": "Competitor research report with PDF output", + "keywords": ["competitor", "research", "report"], + "tenant_visibility": ["*"], + "status": "published", + }, + }, + }, + { + "score": 0.99, + "metadata": { + "listing_id": "lst_private", + "listing": { + "name": "Private", + "tenant_visibility": ["other-tenant"], + "status": "published", + }, + }, + }, + ], + "routing": {}, + }, + ) + + original_init = httpx.Client.__init__ + + def patched_init(self, *args, **kwargs): + kwargs["transport"] = httpx.MockTransport(handler) + original_init(self, *args, **kwargs) + + monkeypatch.setattr(httpx.Client, "__init__", patched_init) + summary = scout.summarize_turn("competitor research report pdf", "", policy(scout)) + results = scout.search_catalog(summary, policy(scout)) + assert requests[0]["agent_id"] == "market:catalog" + assert requests[0]["query"] == summary["summary"] + assert "tenant_id" not in requests[0] + assert [item["listing_id"] for item in results] == ["lst_competitor-scan"]