marketmirror 0.4.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- marketmirror-0.4.2/.gitignore +2 -0
- marketmirror-0.4.2/PKG-INFO +43 -0
- marketmirror-0.4.2/README.md +26 -0
- marketmirror-0.4.2/pyproject.toml +38 -0
- marketmirror-0.4.2/src/marketmirror/__init__.py +1 -0
- marketmirror-0.4.2/src/marketmirror/_server/job_worker.py +36 -0
- marketmirror-0.4.2/src/marketmirror/_server/mcp_server.py +716 -0
- marketmirror-0.4.2/src/marketmirror/_server/mm.sh +226 -0
- marketmirror-0.4.2/src/marketmirror/cli.py +22 -0
- marketmirror-0.4.2/sync.sh +8 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: marketmirror
|
|
3
|
+
Version: 0.4.2
|
|
4
|
+
Summary: MCP server that tests ads, prices, emails and sales pitches on simulated buyers and returns a go / iterate / no-go report.
|
|
5
|
+
Project-URL: Homepage, https://github.com/DarkWorldCoder/market-mirror
|
|
6
|
+
Project-URL: Demo, https://darkworldcoder.github.io/market-mirror/demo/
|
|
7
|
+
Project-URL: Issues, https://github.com/DarkWorldCoder/market-mirror/issues
|
|
8
|
+
Author: Ayush Niroula
|
|
9
|
+
License-Expression: AGPL-3.0-only
|
|
10
|
+
Keywords: ad-testing,agents,llm,market-research,marketing,mcp,mcp-server,simulation,synthetic-users
|
|
11
|
+
Classifier: Operating System :: POSIX
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Office/Business
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# MarketMirror MCP server
|
|
19
|
+
|
|
20
|
+
<!-- mcp-name: io.github.DarkWorldCoder/market-mirror -->
|
|
21
|
+
|
|
22
|
+
MarketMirror tests marketing and sales decisions on simulated buyers before you spend money on real ones. It builds
|
|
23
|
+
interviewed buyer personas, shows them your ads in simulated social feeds, runs a buyer survey and a sales-pitch
|
|
24
|
+
rehearsal, and writes a go / iterate / no-go report with an offline HTML dashboard. Results are simulated and labelled
|
|
25
|
+
as such; `backtest` and `calibrate` compare them with your real ad and survey results.
|
|
26
|
+
|
|
27
|
+
This package is the MCP server (Python standard library only). Add it to any MCP client:
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"mcpServers": {
|
|
32
|
+
"market-mirror": { "command": "uvx", "args": ["marketmirror"] }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Then ask your agent to run `doctor`, and `setup` to install the MarketMirror engine (Git, Bash and
|
|
38
|
+
[uv](https://docs.astral.sh/uv/) required; Linux, macOS or WSL). Choose a model provider in your own terminal with the
|
|
39
|
+
engine's `./mm configure`; never paste API keys into a chat.
|
|
40
|
+
|
|
41
|
+
- Repository, docs and full install guide: https://github.com/DarkWorldCoder/market-mirror
|
|
42
|
+
- Live demo (a 2,999-person simulated run, no install): https://darkworldcoder.github.io/market-mirror/demo/
|
|
43
|
+
- License: AGPL-3.0
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# MarketMirror MCP server
|
|
2
|
+
|
|
3
|
+
<!-- mcp-name: io.github.DarkWorldCoder/market-mirror -->
|
|
4
|
+
|
|
5
|
+
MarketMirror tests marketing and sales decisions on simulated buyers before you spend money on real ones. It builds
|
|
6
|
+
interviewed buyer personas, shows them your ads in simulated social feeds, runs a buyer survey and a sales-pitch
|
|
7
|
+
rehearsal, and writes a go / iterate / no-go report with an offline HTML dashboard. Results are simulated and labelled
|
|
8
|
+
as such; `backtest` and `calibrate` compare them with your real ad and survey results.
|
|
9
|
+
|
|
10
|
+
This package is the MCP server (Python standard library only). Add it to any MCP client:
|
|
11
|
+
|
|
12
|
+
```json
|
|
13
|
+
{
|
|
14
|
+
"mcpServers": {
|
|
15
|
+
"market-mirror": { "command": "uvx", "args": ["marketmirror"] }
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Then ask your agent to run `doctor`, and `setup` to install the MarketMirror engine (Git, Bash and
|
|
21
|
+
[uv](https://docs.astral.sh/uv/) required; Linux, macOS or WSL). Choose a model provider in your own terminal with the
|
|
22
|
+
engine's `./mm configure`; never paste API keys into a chat.
|
|
23
|
+
|
|
24
|
+
- Repository, docs and full install guide: https://github.com/DarkWorldCoder/market-mirror
|
|
25
|
+
- Live demo (a 2,999-person simulated run, no install): https://darkworldcoder.github.io/market-mirror/demo/
|
|
26
|
+
- License: AGPL-3.0
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.24"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "marketmirror"
|
|
7
|
+
version = "0.4.2"
|
|
8
|
+
description = "MCP server that tests ads, prices, emails and sales pitches on simulated buyers and returns a go / iterate / no-go report."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "AGPL-3.0-only"
|
|
12
|
+
authors = [{ name = "Ayush Niroula" }]
|
|
13
|
+
keywords = ["mcp", "mcp-server", "marketing", "market-research", "synthetic-users", "simulation", "ad-testing", "llm", "agents"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Operating System :: POSIX",
|
|
17
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
18
|
+
"Topic :: Office/Business",
|
|
19
|
+
]
|
|
20
|
+
dependencies = []
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/DarkWorldCoder/market-mirror"
|
|
24
|
+
Demo = "https://darkworldcoder.github.io/market-mirror/demo/"
|
|
25
|
+
Issues = "https://github.com/DarkWorldCoder/market-mirror/issues"
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
marketmirror = "marketmirror.cli:main"
|
|
29
|
+
marketmirror-mcp = "marketmirror.cli:main"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["src/marketmirror"]
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel.force-include]
|
|
35
|
+
"src/marketmirror/_server" = "marketmirror/_server"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.sdist.force-include]
|
|
38
|
+
"src/marketmirror/_server" = "src/marketmirror/_server"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""MarketMirror MCP server, packaged for uvx/pip. The server itself lives in plugin/skills/market-mirror/scripts."""
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Detached MCP job worker that persists a terminal status after server restarts."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def write(path, data):
|
|
12
|
+
temp = path + ".tmp"
|
|
13
|
+
with open(temp, "w", encoding="utf-8") as f:
|
|
14
|
+
json.dump(data, f, ensure_ascii=False)
|
|
15
|
+
os.replace(temp, path)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main():
|
|
19
|
+
mm, meta_path, out_path, log_path, *args = sys.argv[1:]
|
|
20
|
+
with open(meta_path, encoding="utf-8") as f:
|
|
21
|
+
meta = json.load(f)
|
|
22
|
+
try:
|
|
23
|
+
with open(out_path, "w", encoding="utf-8") as out, open(log_path, "w", encoding="utf-8") as log:
|
|
24
|
+
code = subprocess.run(["bash", mm, *args], stdout=out, stderr=log).returncode
|
|
25
|
+
except Exception as e:
|
|
26
|
+
with open(log_path, "a", encoding="utf-8") as log:
|
|
27
|
+
log.write(f"job worker failed: {type(e).__name__}: {e}\n")
|
|
28
|
+
code = 1
|
|
29
|
+
meta.update({"status": "done" if code == 0 else "failed", "exit_code": code,
|
|
30
|
+
"finished": time.time(), "elapsed_s": round(time.time() - meta["started"], 2)})
|
|
31
|
+
write(meta_path, meta)
|
|
32
|
+
return code
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
if __name__ == "__main__":
|
|
36
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,716 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
MarketMirror MCP server (stdio or authenticated loopback HTTP, stdlib only).
|
|
4
|
+
|
|
5
|
+
Exposes MarketMirror as tools for any MCP client. It wraps mm.sh,
|
|
6
|
+
so it works before the engine is installed: call `setup` first. Long work (setup, market tests,
|
|
7
|
+
surveys, feed tests) runs as background jobs; poll `job_status`.
|
|
8
|
+
|
|
9
|
+
Register (Claude Code): claude mcp add market-mirror -- python3 /path/to/mcp_server.py
|
|
10
|
+
The plugin registers it automatically.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import argparse
|
|
17
|
+
import hmac
|
|
18
|
+
import os
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
import uuid
|
|
24
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
25
|
+
from typing import Any, Dict, List, Optional
|
|
26
|
+
|
|
27
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
28
|
+
MM = os.path.join(HERE, "mm.sh")
|
|
29
|
+
PROTOCOL = "2025-03-26"
|
|
30
|
+
SUPPORTED_PROTOCOLS = {"2024-11-05", "2025-03-26", "2025-06-18"}
|
|
31
|
+
JOBS: Dict[str, Dict[str, Any]] = {}
|
|
32
|
+
LIVE: Dict[str, Any] = {}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def sh(args: List[str], timeout: float = 120) -> Dict[str, Any]:
|
|
36
|
+
try:
|
|
37
|
+
p = subprocess.run(["bash", MM, *args], capture_output=True, text=True, timeout=timeout)
|
|
38
|
+
return {"ok": p.returncode == 0, "stdout": p.stdout[-20000:], "stderr": p.stderr[-6000:]}
|
|
39
|
+
except subprocess.TimeoutExpired:
|
|
40
|
+
return {"ok": False, "stdout": "", "stderr": f"timed out after {timeout}s"}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _cli_result(result: Dict[str, Any]) -> Dict[str, Any]:
|
|
44
|
+
try:
|
|
45
|
+
data = json.loads(result.get("stdout") or "{}")
|
|
46
|
+
except ValueError:
|
|
47
|
+
data = {"message": (result.get("stdout") or "")[-4000:]}
|
|
48
|
+
if not result["ok"]:
|
|
49
|
+
raise ValueError(data.get("error") or result.get("stderr") or "CLI command failed")
|
|
50
|
+
return data
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def home() -> str:
|
|
54
|
+
return sh(["home"])["stdout"].strip()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def job(args: List[str], label: str) -> Dict[str, Any]:
|
|
58
|
+
jid = uuid.uuid4().hex[:10]
|
|
59
|
+
logdir = os.path.join(os.path.expanduser("~"), ".market-mirror", "jobs")
|
|
60
|
+
os.makedirs(logdir, exist_ok=True)
|
|
61
|
+
log_path = os.path.join(logdir, f"{jid}.log")
|
|
62
|
+
out_path = os.path.join(logdir, f"{jid}.out")
|
|
63
|
+
meta_path = os.path.join(logdir, f"{jid}.json")
|
|
64
|
+
started = time.time()
|
|
65
|
+
with open(meta_path, "w", encoding="utf-8") as f:
|
|
66
|
+
json.dump({"id": jid, "label": label, "args": args, "log": log_path, "out": out_path,
|
|
67
|
+
"started": started, "status": "running"}, f)
|
|
68
|
+
proc = subprocess.Popen([sys.executable, os.path.join(HERE, "job_worker.py"), MM, meta_path, out_path, log_path, *args],
|
|
69
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
70
|
+
JOBS[jid] = {"id": jid, "label": label, "args": args, "proc": proc, "log": log_path, "out": out_path,
|
|
71
|
+
"started": started, "meta": meta_path}
|
|
72
|
+
return {"job_id": jid, "label": label, "status": "running",
|
|
73
|
+
"log_path": log_path, "result_path": out_path,
|
|
74
|
+
"hint": "Poll job_status with this job_id. Progress lines appear in the log tail."}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def job_status(job_id: str, tail: int = 25) -> Dict[str, Any]:
|
|
78
|
+
j = JOBS.get(job_id)
|
|
79
|
+
if not j:
|
|
80
|
+
meta = os.path.join(os.path.expanduser("~"), ".market-mirror", "jobs", f"{job_id}.json")
|
|
81
|
+
try:
|
|
82
|
+
with open(meta, encoding="utf-8") as f:
|
|
83
|
+
j = json.load(f)
|
|
84
|
+
except (OSError, ValueError):
|
|
85
|
+
return {"error": f"unknown job {job_id}"}
|
|
86
|
+
else:
|
|
87
|
+
try:
|
|
88
|
+
with open(j["meta"], encoding="utf-8") as f:
|
|
89
|
+
j = {**j, **json.load(f)}
|
|
90
|
+
except (OSError, ValueError):
|
|
91
|
+
pass
|
|
92
|
+
status = j.get("status", "running")
|
|
93
|
+
if status == "running" and j.get("proc") is not None and j["proc"].poll() is not None:
|
|
94
|
+
status = "done" if j["proc"].returncode == 0 else "failed"
|
|
95
|
+
try:
|
|
96
|
+
with open(j["log"], encoding="utf-8", errors="replace") as f:
|
|
97
|
+
log = f.read().splitlines()[-tail:]
|
|
98
|
+
except OSError:
|
|
99
|
+
log = []
|
|
100
|
+
res: Dict[str, Any] = {"job_id": job_id, "label": j["label"], "elapsed_s": j.get("elapsed_s", round(time.time() - j["started"])),
|
|
101
|
+
"status": status, "log_tail": log, "log_path": j["log"], "result_path": j["out"]}
|
|
102
|
+
if status != "running":
|
|
103
|
+
try:
|
|
104
|
+
with open(j["out"], encoding="utf-8", errors="replace") as f:
|
|
105
|
+
out = f.read().strip()
|
|
106
|
+
except OSError:
|
|
107
|
+
out = ""
|
|
108
|
+
try:
|
|
109
|
+
res["result"] = json.loads(out.splitlines()[-1]) if out else None
|
|
110
|
+
except (ValueError, IndexError):
|
|
111
|
+
res["result"] = out[-4000:]
|
|
112
|
+
return res
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def results(run: str) -> Dict[str, Any]:
|
|
116
|
+
run_dir = run if os.path.isabs(run) else os.path.join(home(), "mirofish_runs", run)
|
|
117
|
+
website = os.path.join(run_dir, "website_test.json")
|
|
118
|
+
if os.path.exists(website) and not os.path.exists(os.path.join(run_dir, "summary.json")):
|
|
119
|
+
with open(website, encoding="utf-8") as source:
|
|
120
|
+
data = json.load(source)
|
|
121
|
+
return {k: data.get(k) for k in ("status", "url", "goal", "summary", "milestones", "files", "interpretation", "limits")}
|
|
122
|
+
path = os.path.join(run_dir, "summary.json")
|
|
123
|
+
if not os.path.exists(path):
|
|
124
|
+
return {"error": f"no summary.json in {run_dir} yet"}
|
|
125
|
+
s = json.load(open(path, encoding="utf-8"))
|
|
126
|
+
mk = s.get("marketing") or {}
|
|
127
|
+
out = {"dashboard": os.path.join(run_dir, "dashboard.html"), "decision": s.get("decision"),
|
|
128
|
+
"report_md": os.path.join(run_dir, "market_report.md") if os.path.exists(os.path.join(run_dir, "market_report.md")) else None,
|
|
129
|
+
"action_plan": (json.load(open(os.path.join(run_dir, "market_report.json"), encoding="utf-8")).get("action_plan")
|
|
130
|
+
if os.path.exists(os.path.join(run_dir, "market_report.json")) else None),
|
|
131
|
+
"verdicts": mk.get("verdicts"), "overall": mk.get("overall"), "playbook": mk.get("playbook"),
|
|
132
|
+
"by_segment": mk.get("by_segment"), "objections": mk.get("objections"),
|
|
133
|
+
"messages": mk.get("messages_ranked"), "pricing": mk.get("pricing"),
|
|
134
|
+
"files": sorted(f for f in os.listdir(run_dir) if f.endswith((".csv", ".json", ".md", ".html")))}
|
|
135
|
+
if os.path.exists(website):
|
|
136
|
+
with open(website, encoding="utf-8") as source:
|
|
137
|
+
data = json.load(source)
|
|
138
|
+
out["website_test"] = {k: data.get(k) for k in ("status", "summary", "milestones", "files", "interpretation")}
|
|
139
|
+
ft = os.path.join(run_dir, "feed_test.json")
|
|
140
|
+
if os.path.exists(ft):
|
|
141
|
+
ftj = json.load(open(ft, encoding="utf-8"))
|
|
142
|
+
summ = ftj.get("summary") or {}
|
|
143
|
+
ms = ftj.get("multi_seed") or {}
|
|
144
|
+
if ms:
|
|
145
|
+
out["feed_test_seeds"] = {"n": ms.get("n"), "winner_agreement": ms.get("winner_agreement"),
|
|
146
|
+
"usage": ms.get("usage"), "spread": ms.get("spread")}
|
|
147
|
+
out["feed_test"] = {cid: {k: v for k, v in m.items() if k in (
|
|
148
|
+
"impressions", "noticed", "thumb_stop", "ctr", "expected_ctr", "engagement_rate", "hide_rate", "landing", "projection")}
|
|
149
|
+
for cid, m in (summ.get("per_creative") or {}).items()}
|
|
150
|
+
out["feed_test_ranked"] = summ.get("ranked")
|
|
151
|
+
pj = os.path.join(run_dir, "pitch.json")
|
|
152
|
+
if os.path.exists(pj):
|
|
153
|
+
pt = json.load(open(pj, encoding="utf-8"))
|
|
154
|
+
out["pitch"] = {k: pt.get(k) for k in ("answered", "pitch_score", "verdict_before", "verdict_after", "fixes", "hot_leads")}
|
|
155
|
+
out["pitch"]["objections"] = [{k: o.get(k) for k in ("type", "share", "rebuttal_success")} for o in pt.get("objections") or []]
|
|
156
|
+
ij = os.path.join(run_dir, "ideas.json")
|
|
157
|
+
if os.path.exists(ij):
|
|
158
|
+
it = json.load(open(ij, encoding="utf-8"))
|
|
159
|
+
out["ideas"] = [{k: i.get(k) for k in ("title", "type", "priority", "hypothesis", "metric", "how_to_test")} for i in it.get("ideas") or []]
|
|
160
|
+
out["ideas_md"] = os.path.join(run_dir, "ideas.md")
|
|
161
|
+
return out
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def list_runs() -> List[Dict[str, Any]]:
|
|
165
|
+
root = os.path.join(home(), "mirofish_runs")
|
|
166
|
+
out = []
|
|
167
|
+
for name in sorted(os.listdir(root)) if os.path.isdir(root) else []:
|
|
168
|
+
p = os.path.join(root, name, "summary.json")
|
|
169
|
+
if os.path.exists(p):
|
|
170
|
+
s = json.load(open(p, encoding="utf-8"))
|
|
171
|
+
out.append({"run": name, "title": (s.get("project_name") or name), "decision": (s.get("decision") or {}).get("decision"),
|
|
172
|
+
"agents": s.get("agents"), "generated_at": s.get("generated_at")})
|
|
173
|
+
return out
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def start_live(run: Optional[str]) -> Dict[str, Any]:
|
|
177
|
+
p = LIVE.get("proc")
|
|
178
|
+
if p and p.poll() is None:
|
|
179
|
+
return {"url": "http://127.0.0.1:5050/", "status": "already running"}
|
|
180
|
+
args = ["live"] + (["--run-dir", os.path.join("mirofish_runs", run)] if run else [])
|
|
181
|
+
LIVE["proc"] = subprocess.Popen(["bash", MM, *args], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
182
|
+
time.sleep(1.5)
|
|
183
|
+
return {"url": "http://127.0.0.1:5050/", "status": "started",
|
|
184
|
+
"hint": "Open the URL in a browser (in the Claude desktop app, the browser pane)."}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def write_config(cfg: Dict[str, Any]) -> str:
|
|
188
|
+
name = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in str(cfg.get("name") or "market_test"))
|
|
189
|
+
d = os.path.join(home(), "scenarios", name)
|
|
190
|
+
os.makedirs(d, exist_ok=True)
|
|
191
|
+
path = os.path.join(d, "market_test.json")
|
|
192
|
+
json.dump(cfg, open(path, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
|
|
193
|
+
return path
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
TOOLS = [
|
|
197
|
+
{"name": "website_test", "description": "Run prepared synthetic visitors on a real website URL. Actions and screenshots are observed; only supplied DOM checks verify goals. Cart changes are enabled by cart/checkout goals; signup submission requires explicit test data and opt-in. No payments/orders. Background job: poll job_status.",
|
|
198
|
+
"inputSchema": {"type": "object", "properties": {"simulation_id": {"type": "string"},
|
|
199
|
+
"spec": {"type": "object", "properties": {"url": {"type": "string"}, "goal": {"type": "string"},
|
|
200
|
+
"task": {"type": "string"}, "checks": {"type": "array", "items": {"type": "object"}},
|
|
201
|
+
"goal_check": {"type": "string"}, "max_personas": {"type": "integer", "minimum": 1, "maximum": 20},
|
|
202
|
+
"max_steps": {"type": "integer", "minimum": 1, "maximum": 30},
|
|
203
|
+
"device": {"type": "string", "enum": ["mobile", "desktop"]},
|
|
204
|
+
"headless": {"type": "boolean"}, "record_video": {"type": "boolean"},
|
|
205
|
+
"record_trace": {"type": "boolean", "description": "Opt-in raw trace may contain test credentials and network data"},
|
|
206
|
+
"max_recoveries": {"type": "integer", "minimum": 0, "maximum": 5},
|
|
207
|
+
"observation_mode": {"type": "string", "enum": ["compact", "full"]},
|
|
208
|
+
"observation_text_chars": {"type": "integer", "minimum": 1000, "maximum": 10000},
|
|
209
|
+
"max_model_calls": {"type": "integer", "minimum": 1, "maximum": 600},
|
|
210
|
+
"max_input_tokens": {"type": "integer", "minimum": 1, "maximum": 100000000},
|
|
211
|
+
"max_elapsed_s": {"type": "number", "exclusiveMinimum": 0, "maximum": 86400},
|
|
212
|
+
"loop_limit": {"type": "integer", "minimum": 2, "maximum": 10},
|
|
213
|
+
"allow_cart": {"type": "boolean"}, "allow_signup_submit": {"type": "boolean"},
|
|
214
|
+
"test_data": {"type": "object", "description": "Owned test identity only. Multiple signup visitors need an email template containing {visitor}. Never provider API keys."}}, "required": ["url"]},
|
|
215
|
+
"out": {"type": "string"}}, "required": ["simulation_id", "spec"]}},
|
|
216
|
+
{"name": "provider_setup", "description": "List provider choices and official key guides, inspect masked local settings, configure a local model or an existing key environment variable, or discover available models. Never paste an API key into tool arguments. ChatGPT/CLI login choices return local login commands.",
|
|
217
|
+
"inputSchema": {"type": "object", "properties": {"action": {"type": "string", "enum": ["list", "status", "configure", "check"]},
|
|
218
|
+
"provider": {"type": "string", "enum": ["ollama", "lmstudio", "anthropic", "openai", "compatible", "siwc", "codex", "claude"]},
|
|
219
|
+
"model": {"type": "string"}, "base_url": {"type": "string"}, "key_env": {"type": "string", "description": "Environment variable NAME only; never the key itself"}}, "required": ["action"]}},
|
|
220
|
+
{"name": "doctor", "description": "Check MarketMirror: engine installed, Python deps, API key configured (never shown), backend running.",
|
|
221
|
+
"inputSchema": {"type": "object", "properties": {}}},
|
|
222
|
+
{"name": "setup", "description": "Install or update the MarketMirror engine (git clone + uv sync). Background job; poll job_status. Needs git and uv.",
|
|
223
|
+
"inputSchema": {"type": "object", "properties": {}}},
|
|
224
|
+
{"name": "start_backend", "description": "Start the MarketMirror backend (port 5001). Configure an API-key, eligible ChatGPT sign-in, local CLI gateway, or local-model provider first.",
|
|
225
|
+
"inputSchema": {"type": "object", "properties": {}}},
|
|
226
|
+
{"name": "market_test", "description": (
|
|
227
|
+
"All-in-one market test. Config keys: name, title, seeds (absolute paths to documents), requirement (text), "
|
|
228
|
+
"personas (persona spec object or absolute path) or auto_personas (int), creatives (feed-test object with creatives, "
|
|
229
|
+
"landing, benchmarks), seeds_n (int, repeat the feed test with N seeds and report ranges), rounds (int), survey {offer, goal, messages (list), split, pricing}, panel {analysts, brief}, "
|
|
230
|
+
"report (bool), simulation_id (reuse), approve_spend (bool). Without approve_spend it stops after creating the "
|
|
231
|
+
"personas so you can show them to the user; then call again with simulation_id and approve_spend true. Background job."),
|
|
232
|
+
"inputSchema": {"type": "object", "properties": {"config": {"type": "object"}}, "required": ["config"]}},
|
|
233
|
+
{"name": "job_status", "description": "Status, log tail and final result of a background job.",
|
|
234
|
+
"inputSchema": {"type": "object", "properties": {"job_id": {"type": "string"}}, "required": ["job_id"]}},
|
|
235
|
+
{"name": "results", "description": "Key results of a run: decision, verdicts, segments, objections, messages, pricing, feed-test metrics, ROI projections, files.",
|
|
236
|
+
"inputSchema": {"type": "object", "properties": {"run": {"type": "string", "description": "run name under mirofish_runs or absolute path"}}, "required": ["run"]}},
|
|
237
|
+
{"name": "list_runs", "description": "List previous runs with their decision.", "inputSchema": {"type": "object", "properties": {}}},
|
|
238
|
+
{"name": "start_live", "description": "Serve the live dashboard at http://127.0.0.1:5050/ (newest run, or a given run).",
|
|
239
|
+
"inputSchema": {"type": "object", "properties": {"run": {"type": "string"}}}},
|
|
240
|
+
{"name": "ads_import", "description": "Turn a Meta/Google Ads export (CSV or JSON, absolute path) into a seed Markdown file plus benchmarks JSON.",
|
|
241
|
+
"inputSchema": {"type": "object", "properties": {"file": {"type": "string"}, "out": {"type": "string", "description": "absolute path of the seed .md to write"}}, "required": ["file", "out"]}},
|
|
242
|
+
{"name": "campaign_plan", "description": "Save an evidence-led campaign plan and runnable test specs from a profile or existing project. No campaign is sent.",
|
|
243
|
+
"inputSchema": {"type": "object", "properties": {"profile": {"type": "object"}, "profile_file": {"type": "string"},
|
|
244
|
+
"project": {"type": "string"}, "research": {"type": "object"}, "research_file": {"type": "string"},
|
|
245
|
+
"goal": {"type": "string"}, "budget": {"type": "object"}, "channels": {"type": "array", "items": {"type": "string"}},
|
|
246
|
+
"out": {"type": "string"}}, "required": ["goal", "budget"]}},
|
|
247
|
+
{"name": "actuals_import", "description": "Normalize a local ads, email, website, CRM or survey CSV/JSON export, retaining provenance and row-level validation errors.",
|
|
248
|
+
"inputSchema": {"type": "object", "properties": {"file": {"type": "string"}, "source_type": {"type": "string", "enum": ["ads", "email", "website", "crm", "survey"]},
|
|
249
|
+
"mapping": {"type": "object", "additionalProperties": {"type": "string"}}, "source": {"type": "string"},
|
|
250
|
+
"market": {"type": "string"}, "date": {"type": "string"}, "quality": {"type": "string"},
|
|
251
|
+
"out": {"type": "string"}}, "required": ["file", "source_type"]}},
|
|
252
|
+
{"name": "holdout_backtest", "description": "Evaluate independent held-out real experiments against a training-mean no-persona baseline.",
|
|
253
|
+
"inputSchema": {"type": "object", "properties": {"pairs": {"type": "array", "items": {"type": "object"}},
|
|
254
|
+
"out": {"type": "string"}}, "required": ["pairs"]}},
|
|
255
|
+
{"name": "backtest", "description": "Score a run's feed test against real ad results (from ads_import): rank correlation, winner match, calibration.",
|
|
256
|
+
"inputSchema": {"type": "object", "properties": {"run": {"type": "string"}, "actuals": {"type": "string"}, "map": {"type": "array", "items": {"type": "string"}}}, "required": ["run", "actuals"]}},
|
|
257
|
+
{"name": "understand", "description": (
|
|
258
|
+
"Step one for any product: read the user's documents (absolute paths to .md/.txt), a landing page URL and/or an ads "
|
|
259
|
+
"export into one product profile (what it is, for whom, pricing, offer, proofs, differentiators, competitors, channels, "
|
|
260
|
+
"likely objections, brand voice, KPIs, unknowns). Every later step uses it. Synchronous, one Claude call. Writes "
|
|
261
|
+
"product_profile.json; pass `project` instead of docs to profile an existing project (also seeds the market map before "
|
|
262
|
+
"the graph build)."),
|
|
263
|
+
"inputSchema": {"type": "object", "properties": {"docs": {"type": "array", "items": {"type": "string"}}, "url": {"type": "string"},
|
|
264
|
+
"ads_file": {"type": "string"}, "project": {"type": "string"}, "name": {"type": "string"},
|
|
265
|
+
"out": {"type": "string"}}}},
|
|
266
|
+
{"name": "quickstart", "description": (
|
|
267
|
+
"Easiest start. Answer a few questions and get a ready scenario folder (brief, personas, two template captions per "
|
|
268
|
+
"platform, messages, launch calendar, pitch) plus the simulated people (stops before any paid rounds). Keys: brand, "
|
|
269
|
+
"product (what, for whom, price), buyers (who and where), platforms (comma list), tests (ads, messages, price, launch, "
|
|
270
|
+
"pitch), budget ('300 USD'), size (small|medium|large), past_ads (absolute path, optional), docs (paths, optional). "
|
|
271
|
+
"Background job; poll job_status. The result names the files to edit and the exact next command."),
|
|
272
|
+
"inputSchema": {"type": "object", "properties": {"answers": {"type": "object"}, "no_run": {"type": "boolean"},
|
|
273
|
+
"from_docs": {"type": "array", "items": {"type": "string"}, "description": "documents to understand first; prefills the answers"},
|
|
274
|
+
"url": {"type": "string"}}, "required": ["answers"]}},
|
|
275
|
+
{"name": "pitch", "description": (
|
|
276
|
+
"Sales rehearsal: pitch a script to every simulated buyer of a simulation (its environment must be alive: run market_test "
|
|
277
|
+
"with rounds first, or start it), collect objections and the questions they ask back, then answer with rebuttals "
|
|
278
|
+
"({objection_type: text, '*': generic}). Scores the pitch, each rebuttal, and every person (lead score). Background job."),
|
|
279
|
+
"inputSchema": {"type": "object", "properties": {"simulation_id": {"type": "string"}, "run": {"type": "string"},
|
|
280
|
+
"script": {"type": "string"}, "rebuttals": {"type": "object"},
|
|
281
|
+
"product": {"type": "string"}, "channel": {"type": "string"},
|
|
282
|
+
"segment": {"type": "string"}}, "required": ["simulation_id", "script"]}},
|
|
283
|
+
{"name": "ideas", "description": (
|
|
284
|
+
"Next test ideas from real data: pass ad rows you fetched from a connected ads tool (Meta/Google/TikTok: name, campaign, "
|
|
285
|
+
"spend, impressions, clicks, conversions, country...) and/or an export file path, plus a previous run (its objections, "
|
|
286
|
+
"audiences and decision). Returns prioritised experiments, each citing its evidence, with the MarketMirror config to "
|
|
287
|
+
"simulate it first. Writes ideas.json/.md into the run folder and adds a 'Test ideas' section to the dashboard."),
|
|
288
|
+
"inputSchema": {"type": "object", "properties": {"rows": {"type": "array", "items": {"type": "object"}}, "ads_file": {"type": "string"},
|
|
289
|
+
"run": {"type": "string"}, "brand": {"type": "string"}, "brief": {"type": "string"},
|
|
290
|
+
"n": {"type": "integer"}}}},
|
|
291
|
+
{"name": "push_context", "description": (
|
|
292
|
+
"While a simulation is running (or before it starts), push what people are saying today into every persona card from the next "
|
|
293
|
+
"round on: a news item, a competitor's move, a real thread you just read with your browser, a price change. Each update is "
|
|
294
|
+
"one or two sentences; the runner re-reads the file every round. Use it to ground the rounds in live reality."),
|
|
295
|
+
"inputSchema": {"type": "object", "properties": {"simulation_id": {"type": "string"}, "text": {"type": "string"}, "round": {"type": "integer"},
|
|
296
|
+
"source": {"type": "string"}}, "required": ["simulation_id", "text"]}},
|
|
297
|
+
{"name": "research", "description": (
|
|
298
|
+
"What real people say about a product, category or competitor, from public keyless sources: Hacker News stories and "
|
|
299
|
+
"comments, App Store listings and recent reviews for a country storefront (in, ph, ke, us...), Reddit public search when "
|
|
300
|
+
"it answers (it often blocks non-browser clients; the result says so, then use your own browser tools and add what you find "
|
|
301
|
+
"as seed files), and any page URLs. Writes research.md (themes with quotes and links), research.json and voices.json (short "
|
|
302
|
+
"real quotes the personas and feeds draw on) under scenarios/<query>/research. Run it before planning personas."),
|
|
303
|
+
"inputSchema": {"type": "object", "properties": {"query": {"type": "string"}, "sources": {"type": "string"}, "country": {"type": "string"},
|
|
304
|
+
"subreddits": {"type": "array", "items": {"type": "string"}}, "urls": {"type": "array", "items": {"type": "string"}},
|
|
305
|
+
"out": {"type": "string"}}, "required": ["query"]}},
|
|
306
|
+
{"name": "predict", "description": (
|
|
307
|
+
"Predictions with intervals for a run: CTR per ad, cost per lead, would-convert overall and per audience, email click rates, "
|
|
308
|
+
"each scaled by the real-vs-simulated results stored so far (from backtest and calibrate) and labelled with its basis "
|
|
309
|
+
"('calibrated on N real results' or 'uncalibrated prior'); optional targets (e.g. {ctr: 2, cpl: 10}) give a probability of "
|
|
310
|
+
"success. Also a 30/60/90-day adoption forecast from the best ad, its landing conversion and the word of mouth seen in the rounds."),
|
|
311
|
+
"inputSchema": {"type": "object", "properties": {"run": {"type": "string"}, "brand": {"type": "string"}, "targets": {"type": "object"},
|
|
312
|
+
"spend": {"type": "number"}, "days": {"type": "integer"}}, "required": ["run"]}},
|
|
313
|
+
{"name": "study", "description": (
|
|
314
|
+
"Several scenarios x several seeds on the same simulated people, one defended conclusion. spec: {name, base (a market_test "
|
|
315
|
+
"config object: creatives, survey, pitch, emails, rounds...), simulation_id (the people to reuse), scenarios: [{name, overrides}], "
|
|
316
|
+
"seeds: [1,2,3], steps: [feed_test, rounds, survey, pitch, email_test]}. Writes study_summary.json (per-scenario mean, spread and "
|
|
317
|
+
"95% interval of every metric, deltas, winner stability, decision agreement matrix, conclusion with confidence), study_report.md "
|
|
318
|
+
"and study.html under mirofish_runs/<name>/. Finished cells are reused on rerun. Background job."),
|
|
319
|
+
"inputSchema": {"type": "object", "properties": {"spec": {"type": "object"}, "out": {"type": "string"}}, "required": ["spec"]}},
|
|
320
|
+
{"name": "run_test", "description": (
|
|
321
|
+
"Test anything from one spec on a prepared simulation: type = ad | message | price | offer | positioning | naming | landing | "
|
|
322
|
+
"launch | pitch | email | creator_post | channel_mix; variants = the things to compare (ad creatives, message lines, prices, "
|
|
323
|
+
"offer wordings, names, landing pages, launch posts with rounds, pitch scripts, emails); audience {segments, countries}; "
|
|
324
|
+
"metric (default per type); budget {amount, cpm, currency}; rounds (launch). Returns one result shape for every type: each "
|
|
325
|
+
"variant's metric with a 95% margin, the winner, the lift, whether it is clear or a tie, and how to read it. Background job."),
|
|
326
|
+
"inputSchema": {"type": "object", "properties": {"simulation_id": {"type": "string"}, "spec": {"type": "object"}, "brand": {"type": "string"},
|
|
327
|
+
"run": {"type": "string"}}, "required": ["simulation_id", "spec"]}},
|
|
328
|
+
{"name": "email_test", "description": (
|
|
329
|
+
"Email campaign test on the simulated people's inboxes: pass emails [{id, from_name, subject, preheader, body, cta, "
|
|
330
|
+
"send_time (morning|midday|evening|night), segments?, sequence?: [{delay_days, subject, body}]}] plus optional list_size and "
|
|
331
|
+
"benchmarks {cost_per_send, lead_value, close_rate, deal_value}. Each person gets a realistic inbox for the day; an "
|
|
332
|
+
"attention gate decides opens, Claude decides skim/read/click/reply/unsubscribe/spam with honest odds. Returns open, click, "
|
|
333
|
+
"click-to-open, unsubscribe and spam rates per variant and audience, subject-line ranking, sequence funnel, projection from "
|
|
334
|
+
"list size to leads and revenue, spam-word warnings. Needs a prepared simulation (simulation_id). Background job."),
|
|
335
|
+
"inputSchema": {"type": "object", "properties": {"simulation_id": {"type": "string"}, "run": {"type": "string"},
|
|
336
|
+
"emails": {"type": "array", "items": {"type": "object"}}, "list_size": {"type": "integer"},
|
|
337
|
+
"benchmarks": {"type": "object"}}, "required": ["simulation_id", "emails"]}},
|
|
338
|
+
{"name": "calibrate", "description": (
|
|
339
|
+
"Check a run's simulated survey against real answers (absolute path to a CSV with segment,country,gender,age,y where y is "
|
|
340
|
+
"yes/maybe/no or 0-1; 50-300 rows is enough): covariate overlap, per-audience calibration, a trust / correct / walk-away "
|
|
341
|
+
"verdict and an AIPW-corrected rate. Writes calibration.json into the run folder."),
|
|
342
|
+
"inputSchema": {"type": "object", "properties": {"run": {"type": "string"}, "real_csv": {"type": "string"},
|
|
343
|
+
"field": {"type": "string"}, "by": {"type": "string"},
|
|
344
|
+
"map": {"type": "array", "items": {"type": "string"}}}, "required": ["run", "real_csv"]}},
|
|
345
|
+
{"name": "cli", "description": "Advanced: run any MarketMirror CLI command as a background job, e.g. ['survey','--simulation','sim_x','--offer','...']. See CLAUDE.md in the engine.",
|
|
346
|
+
"inputSchema": {"type": "object", "properties": {"args": {"type": "array", "items": {"type": "string"}}}, "required": ["args"]}},
|
|
347
|
+
]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def call(name: str, a: Dict[str, Any]) -> Any:
|
|
351
|
+
if name == "website_test":
|
|
352
|
+
directory = os.path.join(home(), "scenarios", "_website_tests")
|
|
353
|
+
os.makedirs(directory, exist_ok=True)
|
|
354
|
+
path = os.path.join(directory, uuid.uuid4().hex[:12] + ".json")
|
|
355
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
356
|
+
with os.fdopen(fd, "w", encoding="utf-8") as output:
|
|
357
|
+
json.dump(a["spec"], output, ensure_ascii=False)
|
|
358
|
+
args = ["cli", "website-test", "--simulation", a["simulation_id"], "--spec", "@" + path]
|
|
359
|
+
if a.get("out"):
|
|
360
|
+
args += ["--out", str(a["out"])]
|
|
361
|
+
return job(args, "website-test")
|
|
362
|
+
if name == "provider_setup":
|
|
363
|
+
args = ["provider", a["action"]]
|
|
364
|
+
for key in ("provider", "model", "base_url", "key_env"):
|
|
365
|
+
if a.get(key):
|
|
366
|
+
args += ["--" + key.replace("_", "-"), str(a[key])]
|
|
367
|
+
return _cli_result(sh(args, timeout=30))
|
|
368
|
+
if name == "doctor":
|
|
369
|
+
r = sh(["doctor"])
|
|
370
|
+
return {"ok": r["ok"], "diagnostics": (r["stderr"] + r["stdout"]).strip()}
|
|
371
|
+
if name == "setup":
|
|
372
|
+
return job(["setup"], "setup")
|
|
373
|
+
if name == "start_backend":
|
|
374
|
+
r = sh(["start"], timeout=300)
|
|
375
|
+
return _cli_result(r)
|
|
376
|
+
if name == "market_test":
|
|
377
|
+
path = write_config(a["config"])
|
|
378
|
+
return {**job(["cli", "market-test", path], "market-test"), "config": path}
|
|
379
|
+
if name == "job_status":
|
|
380
|
+
return job_status(a["job_id"])
|
|
381
|
+
if name == "results":
|
|
382
|
+
return results(a["run"])
|
|
383
|
+
if name == "list_runs":
|
|
384
|
+
return list_runs()
|
|
385
|
+
if name == "start_live":
|
|
386
|
+
return start_live(a.get("run"))
|
|
387
|
+
if name == "ads_import":
|
|
388
|
+
r = sh(["cli", "ads-import", a["file"], "--out", a["out"]], timeout=300)
|
|
389
|
+
return _cli_result(r)
|
|
390
|
+
if name == "campaign_plan":
|
|
391
|
+
if not (a.get("profile") or a.get("profile_file") or a.get("project")):
|
|
392
|
+
raise ValueError("profile, profile_file, or project is required")
|
|
393
|
+
d = os.path.join(home(), "scenarios", "_plans")
|
|
394
|
+
os.makedirs(d, exist_ok=True)
|
|
395
|
+
args = ["cli", "campaign-plan", "--goal", str(a["goal"]), "--budget", str(a["budget"]["amount"]),
|
|
396
|
+
"--currency", str(a["budget"].get("currency") or "USD")]
|
|
397
|
+
if a.get("profile"):
|
|
398
|
+
p = os.path.join(d, f"profile_{uuid.uuid4().hex[:8]}.json")
|
|
399
|
+
with open(p, "w", encoding="utf-8") as f:
|
|
400
|
+
json.dump(a["profile"], f, ensure_ascii=False)
|
|
401
|
+
args += ["--profile", p]
|
|
402
|
+
elif a.get("profile_file"):
|
|
403
|
+
args += ["--profile", str(a["profile_file"])]
|
|
404
|
+
else:
|
|
405
|
+
args += ["--project", str(a["project"])]
|
|
406
|
+
if a.get("research"):
|
|
407
|
+
p = os.path.join(d, f"research_{uuid.uuid4().hex[:8]}.json")
|
|
408
|
+
with open(p, "w", encoding="utf-8") as f:
|
|
409
|
+
json.dump(a["research"], f, ensure_ascii=False)
|
|
410
|
+
args += ["--research", p]
|
|
411
|
+
elif a.get("research_file"):
|
|
412
|
+
args += ["--research", str(a["research_file"])]
|
|
413
|
+
if a.get("channels"):
|
|
414
|
+
args += ["--channels", *[str(c) for c in a["channels"]]]
|
|
415
|
+
if a["budget"].get("period"):
|
|
416
|
+
args += ["--period", str(a["budget"]["period"])]
|
|
417
|
+
args += ["--out", str(a.get("out") or os.path.join(d, "campaign_plan.json"))]
|
|
418
|
+
r = sh(args, timeout=300)
|
|
419
|
+
return _cli_result(r)
|
|
420
|
+
if name == "actuals_import":
|
|
421
|
+
args = ["cli", "actuals-import", str(a["file"]), "--source-type", str(a["source_type"])]
|
|
422
|
+
if a.get("mapping"):
|
|
423
|
+
args += ["--map", *[f"{k}={v}" for k, v in a["mapping"].items()]]
|
|
424
|
+
for key in ("source", "market", "date", "quality", "out"):
|
|
425
|
+
if a.get(key):
|
|
426
|
+
args += ["--" + key, str(a[key])]
|
|
427
|
+
return _cli_result(sh(args, timeout=300))
|
|
428
|
+
if name == "holdout_backtest":
|
|
429
|
+
d = os.path.join(home(), "scenarios", "_backtests")
|
|
430
|
+
os.makedirs(d, exist_ok=True)
|
|
431
|
+
path = os.path.join(d, f"pairs_{uuid.uuid4().hex[:8]}.json")
|
|
432
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
433
|
+
json.dump(a["pairs"], f, ensure_ascii=False)
|
|
434
|
+
args = ["cli", "holdout-backtest", "--pairs", path]
|
|
435
|
+
if a.get("out"):
|
|
436
|
+
args += ["--out", str(a["out"])]
|
|
437
|
+
return _cli_result(sh(args, timeout=60))
|
|
438
|
+
if name == "backtest":
|
|
439
|
+
run_dir = a["run"] if os.path.isabs(a["run"]) else os.path.join("mirofish_runs", a["run"])
|
|
440
|
+
r = sh(["cli", "backtest", "--run-dir", run_dir, "--actuals", a["actuals"], *(["--map", *a["map"]] if a.get("map") else [])], timeout=300)
|
|
441
|
+
return _cli_result(r)
|
|
442
|
+
if name == "understand":
|
|
443
|
+
d = os.path.join(home(), "scenarios", "_profiles")
|
|
444
|
+
os.makedirs(d, exist_ok=True)
|
|
445
|
+
out = a.get("out") or os.path.join(d, f"profile_{uuid.uuid4().hex[:6]}.json")
|
|
446
|
+
args = ["cli", "understand", "--out", out]
|
|
447
|
+
if a.get("project"):
|
|
448
|
+
args += ["--project", a["project"]]
|
|
449
|
+
if a.get("docs"):
|
|
450
|
+
args += ["--docs", *[str(x) for x in a["docs"]]]
|
|
451
|
+
for k in ("url", "name"):
|
|
452
|
+
if a.get(k):
|
|
453
|
+
args += ["--" + k, str(a[k])]
|
|
454
|
+
if a.get("ads_file"):
|
|
455
|
+
args += ["--ads", a["ads_file"]]
|
|
456
|
+
r = sh(args, timeout=400)
|
|
457
|
+
return _cli_result(r)
|
|
458
|
+
if name == "quickstart":
|
|
459
|
+
d = os.path.join(home(), "scenarios", "_quickstart")
|
|
460
|
+
os.makedirs(d, exist_ok=True)
|
|
461
|
+
path = os.path.join(d, f"answers_{uuid.uuid4().hex[:6]}.json")
|
|
462
|
+
json.dump(a["answers"], open(path, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
|
|
463
|
+
args = ["cli", "quickstart", "--answers", "@" + path] + (["--no-run"] if a.get("no_run") else [])
|
|
464
|
+
if a.get("from_docs"):
|
|
465
|
+
args += ["--from-docs", *[str(x) for x in a["from_docs"]]]
|
|
466
|
+
if a.get("url"):
|
|
467
|
+
args += ["--url", str(a["url"])]
|
|
468
|
+
return {**job(args, "quickstart"), "answers": path}
|
|
469
|
+
if name == "pitch":
|
|
470
|
+
run_dir = a.get("run")
|
|
471
|
+
run_dir = (run_dir if os.path.isabs(run_dir) else os.path.join("mirofish_runs", run_dir)) if run_dir else None
|
|
472
|
+
d = os.path.join(home(), "scenarios", "_pitch")
|
|
473
|
+
os.makedirs(d, exist_ok=True)
|
|
474
|
+
sp = os.path.join(d, f"pitch_{uuid.uuid4().hex[:6]}.md")
|
|
475
|
+
open(sp, "w", encoding="utf-8").write(a["script"])
|
|
476
|
+
args = ["cli", "pitch", "--simulation", a["simulation_id"], "--script", "@" + sp]
|
|
477
|
+
if a.get("rebuttals"):
|
|
478
|
+
rp = sp.replace("pitch_", "rebuttals_").replace(".md", ".json")
|
|
479
|
+
json.dump(a["rebuttals"], open(rp, "w", encoding="utf-8"), ensure_ascii=False)
|
|
480
|
+
args += ["--rebuttals", "@" + rp]
|
|
481
|
+
for k in ("product", "channel", "segment"):
|
|
482
|
+
if a.get(k):
|
|
483
|
+
args += ["--" + k, str(a[k])]
|
|
484
|
+
if run_dir:
|
|
485
|
+
args += ["--run-dir", run_dir]
|
|
486
|
+
return job(args, "pitch")
|
|
487
|
+
if name == "ideas":
|
|
488
|
+
run_dir = a.get("run")
|
|
489
|
+
run_dir = (run_dir if os.path.isabs(run_dir) else os.path.join("mirofish_runs", run_dir)) if run_dir else None
|
|
490
|
+
args = ["cli", "ideas"]
|
|
491
|
+
if a.get("rows"):
|
|
492
|
+
d = os.path.join(home(), "scenarios", "_ideas")
|
|
493
|
+
os.makedirs(d, exist_ok=True)
|
|
494
|
+
rp = os.path.join(d, f"rows_{uuid.uuid4().hex[:6]}.json")
|
|
495
|
+
json.dump(a["rows"], open(rp, "w", encoding="utf-8"), ensure_ascii=False)
|
|
496
|
+
args += ["--rows", "@" + rp]
|
|
497
|
+
if a.get("ads_file"):
|
|
498
|
+
args += ["--ads", a["ads_file"]]
|
|
499
|
+
if run_dir:
|
|
500
|
+
args += ["--run-dir", run_dir]
|
|
501
|
+
for k in ("brand", "brief"):
|
|
502
|
+
if a.get(k):
|
|
503
|
+
args += ["--" + k, str(a[k])]
|
|
504
|
+
if a.get("n"):
|
|
505
|
+
args += ["--n", str(int(a["n"]))]
|
|
506
|
+
r = sh(args, timeout=400)
|
|
507
|
+
return _cli_result(r)
|
|
508
|
+
if name == "push_context":
|
|
509
|
+
args = ["cli", "context", "--simulation", a["simulation_id"], "--text", a["text"]]
|
|
510
|
+
if a.get("round") is not None:
|
|
511
|
+
args += ["--round", str(int(a["round"]))]
|
|
512
|
+
if a.get("source"):
|
|
513
|
+
args += ["--source", str(a["source"])]
|
|
514
|
+
r = sh(args, timeout=60)
|
|
515
|
+
return _cli_result(r)
|
|
516
|
+
if name == "research":
|
|
517
|
+
args = ["cli", "research", "--query", a["query"]]
|
|
518
|
+
if a.get("sources"):
|
|
519
|
+
args += ["--sources", str(a["sources"])]
|
|
520
|
+
if a.get("country"):
|
|
521
|
+
args += ["--country", str(a["country"])]
|
|
522
|
+
if a.get("subreddits"):
|
|
523
|
+
args += ["--subreddit", *[str(x) for x in a["subreddits"]]]
|
|
524
|
+
if a.get("urls"):
|
|
525
|
+
args += ["--url", *[str(x) for x in a["urls"]]]
|
|
526
|
+
if a.get("out"):
|
|
527
|
+
args += ["--out", str(a["out"])]
|
|
528
|
+
r = sh(args, timeout=400)
|
|
529
|
+
return _cli_result(r)
|
|
530
|
+
if name == "predict":
|
|
531
|
+
run_dir = a["run"] if os.path.isabs(a["run"]) else os.path.join("mirofish_runs", a["run"])
|
|
532
|
+
args = ["cli", "predict", "--run-dir", run_dir]
|
|
533
|
+
if a.get("brand"):
|
|
534
|
+
args += ["--brand", str(a["brand"])]
|
|
535
|
+
if a.get("targets"):
|
|
536
|
+
args += ["--target", *[f"{k}={v}" for k, v in a["targets"].items()]]
|
|
537
|
+
if a.get("spend"):
|
|
538
|
+
args += ["--spend", str(a["spend"])]
|
|
539
|
+
if a.get("days"):
|
|
540
|
+
args += ["--days", str(int(a["days"]))]
|
|
541
|
+
r = sh(args, timeout=300)
|
|
542
|
+
return _cli_result(r)
|
|
543
|
+
if name == "study":
|
|
544
|
+
d = os.path.join(home(), "scenarios", "_studies")
|
|
545
|
+
os.makedirs(d, exist_ok=True)
|
|
546
|
+
sp = os.path.join(d, f"study_{uuid.uuid4().hex[:6]}.json")
|
|
547
|
+
json.dump(a["spec"], open(sp, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
|
|
548
|
+
args = ["cli", "study", "--spec", sp]
|
|
549
|
+
if a.get("out"):
|
|
550
|
+
args += ["--out", a["out"]]
|
|
551
|
+
return job(args, "study")
|
|
552
|
+
if name == "run_test":
|
|
553
|
+
run_dir = a.get("run")
|
|
554
|
+
run_dir = (run_dir if os.path.isabs(run_dir) else os.path.join("mirofish_runs", run_dir)) if run_dir else None
|
|
555
|
+
d = os.path.join(home(), "scenarios", "_tests")
|
|
556
|
+
os.makedirs(d, exist_ok=True)
|
|
557
|
+
sp = os.path.join(d, f"test_{uuid.uuid4().hex[:6]}.json")
|
|
558
|
+
json.dump(a["spec"], open(sp, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
|
|
559
|
+
args = ["cli", "test", "--simulation", a["simulation_id"], "--spec", "@" + sp]
|
|
560
|
+
if a.get("brand"):
|
|
561
|
+
args += ["--brand", str(a["brand"])]
|
|
562
|
+
if run_dir:
|
|
563
|
+
args += ["--run-dir", run_dir]
|
|
564
|
+
return job(args, f"test {a['spec'].get('type', '')}")
|
|
565
|
+
if name == "email_test":
|
|
566
|
+
run_dir = a.get("run")
|
|
567
|
+
run_dir = (run_dir if os.path.isabs(run_dir) else os.path.join("mirofish_runs", run_dir)) if run_dir else None
|
|
568
|
+
d = os.path.join(home(), "scenarios", "_emails")
|
|
569
|
+
os.makedirs(d, exist_ok=True)
|
|
570
|
+
ep = os.path.join(d, f"emails_{uuid.uuid4().hex[:6]}.json")
|
|
571
|
+
json.dump({"emails": a["emails"], "list_size": a.get("list_size"), "benchmarks": a.get("benchmarks")}, open(ep, "w", encoding="utf-8"), ensure_ascii=False)
|
|
572
|
+
args = ["cli", "emailtest", "--simulation", a["simulation_id"], "--emails", "@" + ep]
|
|
573
|
+
if a.get("list_size"):
|
|
574
|
+
args += ["--list-size", str(int(a["list_size"]))]
|
|
575
|
+
if run_dir:
|
|
576
|
+
args += ["--run-dir", run_dir]
|
|
577
|
+
return job(args, "email-test")
|
|
578
|
+
if name == "calibrate":
|
|
579
|
+
run_dir = a["run"] if os.path.isabs(a["run"]) else os.path.join("mirofish_runs", a["run"])
|
|
580
|
+
args = ["cli", "calibrate", "--run-dir", run_dir, "--real", a["real_csv"]]
|
|
581
|
+
for k in ("field", "by"):
|
|
582
|
+
if a.get(k):
|
|
583
|
+
args += ["--" + k, str(a[k])]
|
|
584
|
+
if a.get("map"):
|
|
585
|
+
args += ["--map", *[str(x) for x in a["map"]]]
|
|
586
|
+
r = sh(args, timeout=300)
|
|
587
|
+
return _cli_result(r)
|
|
588
|
+
if name == "cli":
|
|
589
|
+
return job(["cli", *[str(x) for x in a["args"]]], "cli " + " ".join(map(str, a["args"][:2])))
|
|
590
|
+
raise ValueError(f"unknown tool {name}")
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def reply(msg_id: Any, result: Any = None, error: Optional[Dict[str, Any]] = None) -> None:
|
|
594
|
+
out = {"jsonrpc": "2.0", "id": msg_id, **({"error": error} if error else {"result": result})}
|
|
595
|
+
sys.stdout.write(json.dumps(out, ensure_ascii=False) + "\n")
|
|
596
|
+
sys.stdout.flush()
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def dispatch(msg: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
600
|
+
method, mid = msg.get("method"), msg.get("id")
|
|
601
|
+
if mid is None:
|
|
602
|
+
return None # notification
|
|
603
|
+
if method == "initialize":
|
|
604
|
+
requested = (msg.get("params") or {}).get("protocolVersion")
|
|
605
|
+
result = {"protocolVersion": requested if requested in SUPPORTED_PROTOCOLS else PROTOCOL,
|
|
606
|
+
"capabilities": {"tools": {}},
|
|
607
|
+
"serverInfo": {"name": "market-mirror", "version": "0.4.0"},
|
|
608
|
+
"instructions": "MarketMirror tests marketing and sales decisions on simulated buyers. Start with doctor; "
|
|
609
|
+
"run setup if the engine is missing; use market_test for everything; show the user the "
|
|
610
|
+
"personas before approving spend; report results as simulated directions with their margins."}
|
|
611
|
+
elif method == "ping":
|
|
612
|
+
result = {}
|
|
613
|
+
elif method == "tools/list":
|
|
614
|
+
result = {"tools": TOOLS}
|
|
615
|
+
elif method == "tools/call":
|
|
616
|
+
p = msg.get("params") or {}
|
|
617
|
+
try:
|
|
618
|
+
res = call(p.get("name"), p.get("arguments") or {})
|
|
619
|
+
text = res if isinstance(res, str) else json.dumps(res, ensure_ascii=False, indent=2, default=str)
|
|
620
|
+
result = {"content": [{"type": "text", "text": text}], "isError": False}
|
|
621
|
+
if isinstance(res, (dict, list)):
|
|
622
|
+
result["structuredContent"] = res
|
|
623
|
+
except Exception as e: # noqa: BLE001 - surfaced to the client as a tool error
|
|
624
|
+
result = {"content": [{"type": "text", "text": f"{type(e).__name__}: {e}"}], "isError": True,
|
|
625
|
+
"structuredContent": {"error": str(e), "error_type": type(e).__name__}}
|
|
626
|
+
else:
|
|
627
|
+
return {"jsonrpc": "2.0", "id": mid, "error": {"code": -32601, "message": f"method not found: {method}"}}
|
|
628
|
+
return {"jsonrpc": "2.0", "id": mid, "result": result}
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def handle(msg: Dict[str, Any]) -> None:
|
|
632
|
+
out = dispatch(msg)
|
|
633
|
+
if out is not None:
|
|
634
|
+
sys.stdout.write(json.dumps(out, ensure_ascii=False) + "\n")
|
|
635
|
+
sys.stdout.flush()
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def serve_http(port: int, token: str) -> None:
|
|
639
|
+
"""Stateless MCP Streamable HTTP on authenticated loopback only."""
|
|
640
|
+
if not token or len(token) < 24:
|
|
641
|
+
raise ValueError("MCP bearer token must contain at least 24 characters")
|
|
642
|
+
|
|
643
|
+
class Handler(BaseHTTPRequestHandler):
|
|
644
|
+
def do_POST(self):
|
|
645
|
+
if self.path != "/mcp":
|
|
646
|
+
self.send_error(404)
|
|
647
|
+
return
|
|
648
|
+
given = self.headers.get("Authorization", "")
|
|
649
|
+
if not hmac.compare_digest(given, "Bearer " + token):
|
|
650
|
+
self.send_response(401)
|
|
651
|
+
self.send_header("WWW-Authenticate", "Bearer")
|
|
652
|
+
self.end_headers()
|
|
653
|
+
return
|
|
654
|
+
origin = self.headers.get("Origin", "")
|
|
655
|
+
if origin and not (origin.startswith("http://127.0.0.1:") or origin.startswith("http://localhost:")):
|
|
656
|
+
self.send_error(403)
|
|
657
|
+
return
|
|
658
|
+
try:
|
|
659
|
+
size = int(self.headers.get("Content-Length", "0"))
|
|
660
|
+
if size <= 0 or size > 1_000_000:
|
|
661
|
+
raise ValueError("invalid request size")
|
|
662
|
+
body = json.loads(self.rfile.read(size))
|
|
663
|
+
if not isinstance(body, dict):
|
|
664
|
+
raise ValueError("JSON-RPC request must be an object")
|
|
665
|
+
out = dispatch(body)
|
|
666
|
+
except (ValueError, UnicodeDecodeError) as e:
|
|
667
|
+
self.send_error(400, str(e))
|
|
668
|
+
return
|
|
669
|
+
if out is None:
|
|
670
|
+
self.send_response(202)
|
|
671
|
+
self.end_headers()
|
|
672
|
+
return
|
|
673
|
+
payload = json.dumps(out, ensure_ascii=False).encode("utf-8")
|
|
674
|
+
self.send_response(200)
|
|
675
|
+
self.send_header("Content-Type", "application/json")
|
|
676
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
677
|
+
self.end_headers()
|
|
678
|
+
self.wfile.write(payload)
|
|
679
|
+
|
|
680
|
+
def do_GET(self):
|
|
681
|
+
self.send_error(405)
|
|
682
|
+
|
|
683
|
+
def log_message(self, fmt, *args):
|
|
684
|
+
print(fmt % args, file=sys.stderr)
|
|
685
|
+
|
|
686
|
+
ThreadingHTTPServer(("127.0.0.1", port), Handler).serve_forever()
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def main() -> None:
|
|
690
|
+
parser = argparse.ArgumentParser(description="MarketMirror MCP server")
|
|
691
|
+
parser.add_argument("--http", action="store_true", help="serve authenticated MCP over loopback HTTP")
|
|
692
|
+
parser.add_argument("--port", type=int, default=5061)
|
|
693
|
+
parser.add_argument("--token-file", help="file containing a bearer token (required for --http)")
|
|
694
|
+
opts = parser.parse_args()
|
|
695
|
+
if opts.http:
|
|
696
|
+
if not opts.token_file:
|
|
697
|
+
parser.error("--http requires --token-file")
|
|
698
|
+
with open(opts.token_file, encoding="utf-8") as f:
|
|
699
|
+
token = f.read().strip()
|
|
700
|
+
serve_http(opts.port, token)
|
|
701
|
+
return
|
|
702
|
+
lock = threading.Lock()
|
|
703
|
+
for line in sys.stdin:
|
|
704
|
+
line = line.strip()
|
|
705
|
+
if not line:
|
|
706
|
+
continue
|
|
707
|
+
try:
|
|
708
|
+
msg = json.loads(line)
|
|
709
|
+
except ValueError:
|
|
710
|
+
continue
|
|
711
|
+
with lock:
|
|
712
|
+
handle(msg)
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
if __name__ == "__main__":
|
|
716
|
+
main()
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# mm.sh - MarketMirror helper for agents: simulated buyers, ad/message/price tests (MiroFish engine).
|
|
3
|
+
#
|
|
4
|
+
# mm.sh setup clone or update the engine, install Python deps, create .env
|
|
5
|
+
# mm.sh doctor check repo, deps, API key (never printed) and backend
|
|
6
|
+
# mm.sh configure choose local models, hosted API, or a login gateway (hidden key input)
|
|
7
|
+
# mm.sh provider ACTION list/status/check/configure provider settings; use --key-env for agent setup
|
|
8
|
+
# mm.sh agent-config print portable MCP config (--client codex|claude|cursor|gemini|generic)
|
|
9
|
+
# mm.sh start | stop [--port N] [--env-file F] start or stop a backend (default port 5001; other ports keep
|
|
10
|
+
# their data in backend/uploads-<port>, so several backends run side by side)
|
|
11
|
+
# mm.sh scenario NAME FILE... copy documents into <home>/scenarios/NAME/ and print that folder
|
|
12
|
+
# mm.sh cli ARGS... run the MiroFish CLI from the engine folder (see its --help)
|
|
13
|
+
# mm.sh live [ARGS...] serve the live dashboard on http://127.0.0.1:5050/ (foreground)
|
|
14
|
+
# mm.sh gateway login|start|stop|status [claude|codex|gemini|antigravity|chatgpt|siwc|multi] [--port N] [--model M]
|
|
15
|
+
# local login gateway (default port 5055, writes .env); --port N runs another one
|
|
16
|
+
# side by side and writes .env.<backend> for a backend started with --env-file
|
|
17
|
+
# mm.sh home print the engine folder
|
|
18
|
+
# mm.sh mcp run the MCP server (stdio) exposing all of this as agent tools
|
|
19
|
+
#
|
|
20
|
+
# Environment: MARKET_MIRROR_HOME (default ~/.market-mirror/engine; MIROFISH_HOME also accepted),
|
|
21
|
+
# MARKET_MIRROR_REPO (default https://github.com/DarkWorldCoder/market-mirror.git)
|
|
22
|
+
set -euo pipefail
|
|
23
|
+
|
|
24
|
+
REPO_URL="${MARKET_MIRROR_REPO:-${MIROFISH_REPO:-https://github.com/DarkWorldCoder/market-mirror.git}}"
|
|
25
|
+
HOME_DIR="${MARKET_MIRROR_HOME:-${MIROFISH_HOME:-}}"
|
|
26
|
+
if [ -z "$HOME_DIR" ]; then
|
|
27
|
+
registry="${MARKET_MIRROR_CONFIG_DIR:-$HOME/.market-mirror}/engine.json"
|
|
28
|
+
HOME_DIR=$(python3 - "$registry" <<'PY'
|
|
29
|
+
import json,sys
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
try:
|
|
32
|
+
candidate = Path(json.loads(Path(sys.argv[1]).read_text())["path"])
|
|
33
|
+
if candidate.is_absolute() and (candidate / "backend/claude_cli/mirofish.py").is_file():
|
|
34
|
+
print(candidate)
|
|
35
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
36
|
+
pass
|
|
37
|
+
PY
|
|
38
|
+
)
|
|
39
|
+
HOME_DIR="${HOME_DIR:-$HOME/.market-mirror/engine}"
|
|
40
|
+
fi
|
|
41
|
+
PY="$HOME_DIR/backend/.venv/bin/python"
|
|
42
|
+
CLI="$HOME_DIR/backend/claude_cli/mirofish.py"
|
|
43
|
+
|
|
44
|
+
say() { printf '%s\n' "$*" >&2; }
|
|
45
|
+
die() { say "error: $*"; exit 1; }
|
|
46
|
+
|
|
47
|
+
key_status() {
|
|
48
|
+
# Reports whether a key is configured without ever reading it into a variable or printing it.
|
|
49
|
+
local env="$HOME_DIR/.env"
|
|
50
|
+
# hosted OpenAI-compatible providers (API_PROVIDERS in backend/app/utils/model_pool.py)
|
|
51
|
+
local hosted="DEEPSEEK MOONSHOT KIMI MISTRAL XAI GROK GROQ TOGETHER FIREWORKS OPENROUTER DASHSCOPE QWEN ZAI ZHIPU CEREBRAS MINIMAX"
|
|
52
|
+
if [ -n "${ANTHROPIC_API_KEY:-}" ] || [ -n "${GEMINI_API_KEY:-}" ] || [ -n "${GOOGLE_API_KEY:-}" ] || [ -n "${OPENAI_API_KEY:-}" ] || [ -n "${LLM_API_KEY:-}" ]; then echo "set (environment)"; return; fi
|
|
53
|
+
local name; for name in $hosted; do
|
|
54
|
+
name="${name}_API_KEY"
|
|
55
|
+
if [ -n "${!name:-}" ]; then echo "set (environment)"; return; fi
|
|
56
|
+
done
|
|
57
|
+
[ -f "$env" ] || { echo "missing (.env not created)"; return; }
|
|
58
|
+
if grep -Eq "^(${hosted// /|})_API_KEY=[[:space:]]*[\"']?[^\"'[:space:]]" "$env"; then echo "set (.env)"; return; fi
|
|
59
|
+
if grep -Eq '^LLM_API_KEY=[[:space:]]*("|'"'"')?(your_api_key_here)?("|'"'"')?[[:space:]]*$' "$env" \
|
|
60
|
+
|| ! grep -Eq '^LLM_API_KEY=' "$env"; then
|
|
61
|
+
echo "missing (edit LLM_API_KEY in $env)"
|
|
62
|
+
else
|
|
63
|
+
echo "set (.env)"
|
|
64
|
+
fi
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
backend_up() { curl -fsS -m 3 "${MIROFISH_URL:-http://localhost:5001}/health" >/dev/null 2>&1; }
|
|
68
|
+
|
|
69
|
+
doctor() {
|
|
70
|
+
local ok=1
|
|
71
|
+
[ -d "$HOME_DIR/.git" ] && say "engine: $HOME_DIR" || { say "engine: missing (run: mm.sh setup)"; ok=0; }
|
|
72
|
+
[ -x "$PY" ] && say "python: $("$PY" --version 2>&1)" || { say "python: deps not installed (run: mm.sh setup)"; ok=0; }
|
|
73
|
+
local k; k=$(key_status); say "model credential: $k"
|
|
74
|
+
case $k in set*) ;; *)
|
|
75
|
+
if grep -Eq '^LLM_BASE_URL=http://127\.0\.0\.1:5055' "$HOME_DIR/.env" 2>/dev/null; then
|
|
76
|
+
say "gateway: configured (LLM_GATEWAY=$(grep -E '^LLM_GATEWAY=' "$HOME_DIR/.env" | cut -d= -f2-)); $(curl -fsS -m 2 http://127.0.0.1:5055/health >/dev/null 2>&1 && echo running || echo 'stopped (run: mm.sh gateway start)')"
|
|
77
|
+
else
|
|
78
|
+
ok=0
|
|
79
|
+
for cli in claude codex antigravity gemini; do
|
|
80
|
+
if [ -x "$PY" ] && "$PY" "$HOME_DIR/backend/gateway/local_gateway.py" --backend "$cli" --check >/dev/null 2>&1; then
|
|
81
|
+
say "no key, but the $cli CLI is installed: 'mm.sh gateway start $cli' runs on its login (personal use; add --port N to run several side by side)"
|
|
82
|
+
fi
|
|
83
|
+
done
|
|
84
|
+
say "multi-agent pool: 'mm.sh gateway start multi' pools all installed local CLIs together"
|
|
85
|
+
fi ;;
|
|
86
|
+
esac
|
|
87
|
+
if [ -f "$HOME_DIR/.env" ]; then
|
|
88
|
+
say "model: $(grep -E '^LLM_MODEL_NAME=' "$HOME_DIR/.env" | cut -d= -f2- || true)"
|
|
89
|
+
say "memory: $(grep -E '^MEMORY_BACKEND=' "$HOME_DIR/.env" | cut -d= -f2- || true)"
|
|
90
|
+
fi
|
|
91
|
+
backend_up && say "backend: running" || say "backend: stopped (run: mm.sh start)"
|
|
92
|
+
[ $ok -eq 1 ] && say "ready" || { say "not ready"; return 1; }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
cmd="${1:-help}"; shift || true
|
|
96
|
+
case "$cmd" in
|
|
97
|
+
home) echo "$HOME_DIR" ;;
|
|
98
|
+
setup)
|
|
99
|
+
command -v git >/dev/null || die "git is required"
|
|
100
|
+
if [ -d "$HOME_DIR/.git" ]; then
|
|
101
|
+
say "using existing checkout $HOME_DIR (update separately with git pull --ff-only)"
|
|
102
|
+
else
|
|
103
|
+
say "cloning $REPO_URL -> $HOME_DIR"; mkdir -p "$(dirname "$HOME_DIR")"; git clone -q --depth 1 "$REPO_URL" "$HOME_DIR"
|
|
104
|
+
fi
|
|
105
|
+
command -v uv >/dev/null || die "uv is required. Install it with one of: 'brew install uv', 'pipx install uv', 'pip install uv' (see https://docs.astral.sh/uv/), then run setup again"
|
|
106
|
+
say "installing Python deps (first time takes a few minutes)"
|
|
107
|
+
(cd "$HOME_DIR/backend" && uv sync -q --python 3.12)
|
|
108
|
+
[ -f "$HOME_DIR/.env" ] || { cp "$HOME_DIR/.env.example" "$HOME_DIR/.env"; say "created $HOME_DIR/.env"; }
|
|
109
|
+
doctor || true
|
|
110
|
+
;;
|
|
111
|
+
doctor) doctor ;;
|
|
112
|
+
agent-config)
|
|
113
|
+
exec python3 "$HOME_DIR/backend/claude_cli/agent_install.py" --root "$HOME_DIR" "$@"
|
|
114
|
+
;;
|
|
115
|
+
configure|provider)
|
|
116
|
+
action="configure"
|
|
117
|
+
if [ "$cmd" = provider ]; then action="${1:-list}"; [ $# -eq 0 ] || shift; fi
|
|
118
|
+
provider_py="$PY"; [ -x "$provider_py" ] || provider_py="$(command -v python3)"
|
|
119
|
+
exec "$provider_py" "$HOME_DIR/backend/claude_cli/providers.py" "$action" --root "$HOME_DIR" "$@"
|
|
120
|
+
;;
|
|
121
|
+
start)
|
|
122
|
+
[ -x "$PY" ] || die "not set up yet (run: mm.sh setup)"
|
|
123
|
+
case " $* " in
|
|
124
|
+
*" --env-file"*) ;; # a per-instance settings file (written by: mm.sh gateway start BACKEND --port N)
|
|
125
|
+
*) case $(key_status) in set*) ;; *) die "provider not configured; run mm.sh configure (local models and login gateways are available)" ;; esac ;;
|
|
126
|
+
esac
|
|
127
|
+
cd "$HOME_DIR" && bash backend/claude_cli/start_server.sh "$@"
|
|
128
|
+
;;
|
|
129
|
+
stop) cd "$HOME_DIR" && bash backend/claude_cli/stop_server.sh "$@" ;;
|
|
130
|
+
scenario)
|
|
131
|
+
name="${1:-}"; [ -n "$name" ] || die "usage: mm.sh scenario NAME FILE..."; shift
|
|
132
|
+
case "$name" in .*|*[!A-Za-z0-9._-]*) die "scenario name must start with a letter or digit and use only letters, digits, . _ -" ;; esac
|
|
133
|
+
dir="$HOME_DIR/scenarios/$name"; mkdir -p "$dir"
|
|
134
|
+
for f in "$@"; do [ -f "$f" ] || die "not a file: $f"; cp "$f" "$dir/"; done
|
|
135
|
+
echo "$dir"
|
|
136
|
+
;;
|
|
137
|
+
cli)
|
|
138
|
+
[ -x "$PY" ] || die "not set up yet (run: mm.sh setup)"
|
|
139
|
+
cd "$HOME_DIR" && exec "$PY" "$CLI" "$@"
|
|
140
|
+
;;
|
|
141
|
+
live)
|
|
142
|
+
[ -x "$PY" ] || die "not set up yet (run: mm.sh setup)"
|
|
143
|
+
cd "$HOME_DIR" && exec "$PY" "$CLI" live "$@"
|
|
144
|
+
;;
|
|
145
|
+
gateway)
|
|
146
|
+
[ -x "$PY" ] || die "not set up yet (run: mm.sh setup)"
|
|
147
|
+
gw_usage="usage: mm.sh gateway login|start|stop|status [claude|codex|gemini|antigravity|chatgpt|siwc|multi] [--port N] [--model M] [--env-file F]"
|
|
148
|
+
sub="${1:-status}"; [ $# -eq 0 ] || shift
|
|
149
|
+
be=""; port="5055"; gw_model=""; envf=""
|
|
150
|
+
while [ $# -gt 0 ]; do
|
|
151
|
+
case "$1" in
|
|
152
|
+
--port) port="${2:-}"; shift ;;
|
|
153
|
+
--port=*) port="${1#*=}" ;;
|
|
154
|
+
--model) gw_model="${2:-}"; shift ;;
|
|
155
|
+
--model=*) gw_model="${1#*=}" ;;
|
|
156
|
+
--env-file) envf="${2:-}"; shift ;;
|
|
157
|
+
--env-file=*) envf="${1#*=}" ;;
|
|
158
|
+
-*) die "$gw_usage" ;;
|
|
159
|
+
*) [ -z "$be" ] || die "$gw_usage"; be="$1" ;;
|
|
160
|
+
esac
|
|
161
|
+
shift
|
|
162
|
+
done
|
|
163
|
+
case "$port" in ''|*[!0-9]*) die "--port must be a number" ;; esac
|
|
164
|
+
if [ -z "$be" ] && [ "$sub" = start ]; then
|
|
165
|
+
be="$(grep -E '^LLM_GATEWAY=' "$HOME_DIR/.env" 2>/dev/null | cut -d= -f2- | tr -d '"')"; be="${be:-claude}"
|
|
166
|
+
fi
|
|
167
|
+
case "${be:-claude}" in claude|codex|gemini|antigravity|chatgpt|siwc|multi) ;; *) die "unknown gateway backend '$be'; $gw_usage" ;; esac
|
|
168
|
+
# one pid/token/log file per port, so several gateways (one per provider) run side by side
|
|
169
|
+
if [ "$port" = 5055 ]; then sfx=""; else sfx="-$port"; fi
|
|
170
|
+
mkdir -p "$HOME_DIR/backend/logs"
|
|
171
|
+
pidf="$HOME_DIR/backend/logs/gateway$sfx.pid"
|
|
172
|
+
tokenf="$HOME_DIR/backend/logs/gateway$sfx.token"
|
|
173
|
+
logf="$HOME_DIR/backend/logs/gateway$sfx.out"
|
|
174
|
+
url="http://127.0.0.1:$port"
|
|
175
|
+
case "$sub" in
|
|
176
|
+
login) cd "$HOME_DIR" && exec "$PY" backend/gateway/siwc_auth.py login ;;
|
|
177
|
+
start)
|
|
178
|
+
if [ "$be" = siwc ]; then
|
|
179
|
+
"$PY" "$HOME_DIR/backend/gateway/siwc_auth.py" status | "$PY" -c 'import json,sys; s=json.load(sys.stdin); sys.exit(0 if s.get("eligible_scope") and not s.get("needs_login") else 1)' || die "Continue with ChatGPT is not ready (run: mm.sh gateway login)"
|
|
180
|
+
else
|
|
181
|
+
# the gateway finds each CLI on PATH or in its known install locations (codex in the ChatGPT app, agy in ~/.local/bin)
|
|
182
|
+
"$PY" "$HOME_DIR/backend/gateway/local_gateway.py" --backend "$be" --check >/dev/null 2>&1 \
|
|
183
|
+
|| die "'$be' is not available (install its CLI and log in first; for multi, at least one of claude, codex, antigravity, gemini)"
|
|
184
|
+
fi
|
|
185
|
+
if [ ! -s "$tokenf" ]; then (umask 077; "$PY" -c 'import secrets,sys; open(sys.argv[1],"w").write(secrets.token_urlsafe(48))' "$tokenf"); fi
|
|
186
|
+
if curl -fsS -m 2 "$url/health" >/dev/null 2>&1; then
|
|
187
|
+
curl -fsS -m 2 "$url/health" | "$PY" -c 'import json,sys; s=json.load(sys.stdin); sys.exit(0 if s.get("auth_required") and s.get("backend")==sys.argv[1] else 1)' "$be" || die "a different or unauthenticated gateway is on port $port; stop it or pick another --port"
|
|
188
|
+
say "gateway already running ($be on $url)"
|
|
189
|
+
else
|
|
190
|
+
margs=(); [ -n "$gw_model" ] && margs=(--model "$gw_model")
|
|
191
|
+
# exec, so the recorded pid is the gateway itself (stop kills it) and nothing keeps the caller's output open
|
|
192
|
+
(cd "$HOME_DIR" && exec nohup "$PY" backend/gateway/local_gateway.py --backend "$be" --port "$port" --auth-token-file "$tokenf" ${margs[@]+"${margs[@]}"} > "$logf" 2>&1 < /dev/null) &
|
|
193
|
+
echo $! > "$pidf"
|
|
194
|
+
for _ in $(seq 1 20); do curl -fsS -m 1 "$url/health" >/dev/null 2>&1 && break; sleep 0.5; done
|
|
195
|
+
curl -fsS -m 1 "$url/health" >/dev/null 2>&1 || { tail -n 20 "$logf" >&2; die "gateway did not start; see $logf"; }
|
|
196
|
+
say "gateway started ($be) on $url/v1 (log $logf)"
|
|
197
|
+
fi
|
|
198
|
+
model="${gw_model:-$be}"
|
|
199
|
+
if [ "$be" = siwc ] && [ -z "$gw_model" ]; then model=$("$PY" "$HOME_DIR/backend/gateway/siwc_auth.py" status | "$PY" -c 'import json,sys; print(json.load(sys.stdin).get("model") or "")'); fi
|
|
200
|
+
# settings: the default port writes .env (as before); other ports write a settings file for that provider,
|
|
201
|
+
# used by a backend instance started with: mm.sh start --port P --env-file F
|
|
202
|
+
if [ -z "$envf" ]; then
|
|
203
|
+
if [ "$port" = 5055 ]; then envf="$HOME_DIR/.env"; else envf="$HOME_DIR/.env.$be"; fi
|
|
204
|
+
fi
|
|
205
|
+
case "$envf" in /*) ;; *) envf="$PWD/$envf" ;; esac
|
|
206
|
+
base_env="$envf"; [ -f "$base_env" ] || base_env="$HOME_DIR/.env"
|
|
207
|
+
(umask 077
|
|
208
|
+
{ grep -Ev '^(LLM_PROVIDER|LLM_BASE_URL|LLM_API_KEY|LLM_GATEWAY|LLM_MODEL_NAME|LLM_POOL|MARKET_MIRROR_PROVIDER)=' "$base_env" 2>/dev/null || true
|
|
209
|
+
printf 'LLM_PROVIDER=openai\nLLM_BASE_URL=%s/v1\nLLM_API_KEY=%s\nLLM_GATEWAY=%s\nLLM_MODEL_NAME=%s\n' "$url" "$(cat "$tokenf")" "$be" "$model"; } > "$envf.tmp")
|
|
210
|
+
mv "$envf.tmp" "$envf"
|
|
211
|
+
if [ "$envf" = "$HOME_DIR/.env" ]; then
|
|
212
|
+
say "wrote gateway settings to $envf (restart the backend: mm.sh stop && mm.sh start)"
|
|
213
|
+
else
|
|
214
|
+
say "wrote gateway settings to $envf; start a backend on it with:"
|
|
215
|
+
say " mm.sh start --port <backend port, e.g. 5011> --env-file $envf"
|
|
216
|
+
say " (its data goes to backend/uploads-<port>; point the CLI at it with MIROFISH_URL=http://localhost:<port>)"
|
|
217
|
+
fi ;;
|
|
218
|
+
stop)
|
|
219
|
+
if [ -f "$pidf" ] && kill "$(cat "$pidf")" 2>/dev/null; then rm -f "$pidf"; say "gateway on port $port stopped"; else rm -f "$pidf"; say "no gateway running on port $port"; fi ;;
|
|
220
|
+
status) curl -fsS -m 2 "$url/health" 2>/dev/null || say "no gateway on port $port" ;;
|
|
221
|
+
*) die "$gw_usage" ;;
|
|
222
|
+
esac ;;
|
|
223
|
+
mcp) exec python3 "$(dirname "$0")/mcp_server.py" "$@" ;;
|
|
224
|
+
help|-h|--help) sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//' ;;
|
|
225
|
+
*) die "unknown command '$cmd' (try: mm.sh help)" ;;
|
|
226
|
+
esac
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Run the bundled MarketMirror MCP server (stdio by default; pass --help for options).
|
|
2
|
+
|
|
3
|
+
The server is the same file the Claude Code plugin and Gemini extension use. It drives the MarketMirror engine through
|
|
4
|
+
the bundled mm.sh helper; the engine itself is installed on first use with the `setup` tool (into
|
|
5
|
+
~/.market-mirror/engine, or MARKET_MIRROR_HOME).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import runpy
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main() -> None:
|
|
14
|
+
server = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_server", "mcp_server.py")
|
|
15
|
+
if not os.path.isfile(server):
|
|
16
|
+
sys.exit("marketmirror: bundled server files are missing from this install")
|
|
17
|
+
sys.argv[0] = server
|
|
18
|
+
runpy.run_path(server, run_name="__main__")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
if __name__ == "__main__":
|
|
22
|
+
main()
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Copy the MCP server files from the plugin into the package before building (they are not kept twice in git).
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
here="$(cd "$(dirname "$0")" && pwd)"
|
|
5
|
+
src="$here/../../plugin/skills/market-mirror/scripts"
|
|
6
|
+
dst="$here/src/marketmirror/_server"
|
|
7
|
+
rm -rf "$dst"; mkdir -p "$dst"
|
|
8
|
+
cp "$src/mcp_server.py" "$src/job_worker.py" "$src/mm.sh" "$dst/"
|