moorlight 0.1.0__py3-none-any.whl
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.
- moorlight/__init__.py +3 -0
- moorlight/__main__.py +110 -0
- moorlight/chat.py +175 -0
- moorlight/dicts.py +63 -0
- moorlight/edit.py +630 -0
- moorlight/maps.py +243 -0
- moorlight/mcp.py +110 -0
- moorlight/model.py +247 -0
- moorlight/rsr.py +642 -0
- moorlight/server.py +188 -0
- moorlight/session.py +858 -0
- moorlight/web/__init__.py +1 -0
- moorlight/web/app.js +1194 -0
- moorlight/web/index.html +138 -0
- moorlight/web/style.css +86 -0
- moorlight-0.1.0.dist-info/METADATA +70 -0
- moorlight-0.1.0.dist-info/RECORD +21 -0
- moorlight-0.1.0.dist-info/WHEEL +5 -0
- moorlight-0.1.0.dist-info/entry_points.txt +3 -0
- moorlight-0.1.0.dist-info/licenses/LICENSE +21 -0
- moorlight-0.1.0.dist-info/top_level.txt +1 -0
moorlight/__init__.py
ADDED
moorlight/__main__.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""moorlight [files or TorchRef result dirs...] — start the viewer server and open the browser.
|
|
2
|
+
|
|
3
|
+
python -m moorlight refined.pdb refined.mtz
|
|
4
|
+
python -m moorlight path/to/torchref/run/
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import os
|
|
10
|
+
import socket
|
|
11
|
+
import threading
|
|
12
|
+
import webbrowser
|
|
13
|
+
|
|
14
|
+
os.environ.setdefault("TORCHREF_DEVICE", "cpu")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _free_port(preferred, wait=5.0):
|
|
18
|
+
"""The preferred port, waiting briefly for a previous server to release it; else the next free one."""
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
def free(p):
|
|
22
|
+
with socket.socket() as s:
|
|
23
|
+
try:
|
|
24
|
+
s.bind(("127.0.0.1", p))
|
|
25
|
+
return True
|
|
26
|
+
except OSError:
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
t0 = time.time()
|
|
30
|
+
while time.time() - t0 < wait:
|
|
31
|
+
if free(preferred):
|
|
32
|
+
return preferred
|
|
33
|
+
time.sleep(0.25)
|
|
34
|
+
for p in range(preferred + 1, preferred + 50):
|
|
35
|
+
if free(p):
|
|
36
|
+
print(f"port {preferred} is busy, using {p}")
|
|
37
|
+
return p
|
|
38
|
+
raise RuntimeError("no free port")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def main():
|
|
42
|
+
ap = argparse.ArgumentParser(prog="moorlight", description=__doc__, formatter_class=argparse.RawTextHelpFormatter)
|
|
43
|
+
ap.add_argument("files", nargs="*", help="PDB/mmCIF models, MTZ files, CCP4 maps, restraint CIFs or TorchRef dirs")
|
|
44
|
+
ap.add_argument("--port", type=int, default=int(os.environ.get("PORT", 8765)))
|
|
45
|
+
ap.add_argument("--no-browser", action="store_true")
|
|
46
|
+
ap.add_argument("--workdir", default=None, help="directory for chat / relative paths (default: first file's dir)")
|
|
47
|
+
ap.add_argument("--sample-rate", type=float, default=3.0, help="map grid sampling (Coot-like 3 ≈ d/6 spacing)")
|
|
48
|
+
ap.add_argument("--allow-bash", action="store_true",
|
|
49
|
+
help="let the built-in Claude chat run shell commands (e.g. TorchRef) without asking")
|
|
50
|
+
ap.add_argument("--model", default=None, help="Claude model for the built-in chat")
|
|
51
|
+
a = ap.parse_args()
|
|
52
|
+
|
|
53
|
+
from aiohttp import web
|
|
54
|
+
from .server import App, write_state_file
|
|
55
|
+
from .session import Session
|
|
56
|
+
|
|
57
|
+
files = [os.path.abspath(f) for f in a.files]
|
|
58
|
+
workdir = a.workdir or (files[0] if files and os.path.isdir(files[0]) else
|
|
59
|
+
os.path.dirname(files[0]) if files else os.getcwd())
|
|
60
|
+
port = _free_port(a.port)
|
|
61
|
+
s = Session(workdir=os.path.abspath(workdir), sample_rate=a.sample_rate)
|
|
62
|
+
app = App(s, chat_opts={"allow_bash": a.allow_bash, "model": a.model, "port": port})
|
|
63
|
+
|
|
64
|
+
for f in files:
|
|
65
|
+
low = f.lower()
|
|
66
|
+
try:
|
|
67
|
+
if os.path.isdir(f):
|
|
68
|
+
s.load_torchref(f)
|
|
69
|
+
elif low.endswith((".pdb", ".ent")) or (low.endswith((".cif", ".mmcif")) and _is_model(f)):
|
|
70
|
+
s.load_model(f)
|
|
71
|
+
elif low.endswith(".cif"):
|
|
72
|
+
s.add_dictionary(f)
|
|
73
|
+
elif low.endswith(".mtz"):
|
|
74
|
+
s.load_mtz(f)
|
|
75
|
+
elif low.endswith((".map", ".ccp4", ".mrc")):
|
|
76
|
+
s.load_map(f)
|
|
77
|
+
else:
|
|
78
|
+
print(f"skipping {f}: unknown type")
|
|
79
|
+
except Exception as e:
|
|
80
|
+
print(f"could not load {f}: {e}")
|
|
81
|
+
|
|
82
|
+
from .rsr import warm_up
|
|
83
|
+
threading.Thread(target=lambda: _quiet(warm_up), daemon=True).start()
|
|
84
|
+
|
|
85
|
+
url = f"http://127.0.0.1:{port}/"
|
|
86
|
+
write_state_file(url, s.workdir)
|
|
87
|
+
print(f"Moorlight: {url}")
|
|
88
|
+
if not a.no_browser:
|
|
89
|
+
threading.Timer(0.8, lambda: webbrowser.open(url)).start()
|
|
90
|
+
web.run_app(app.make_app(), host="127.0.0.1", port=port, print=None)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _quiet(fn):
|
|
94
|
+
try:
|
|
95
|
+
fn()
|
|
96
|
+
except Exception as e:
|
|
97
|
+
print(f"TorchRef warm-up failed: {e}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _is_model(path):
|
|
101
|
+
import gemmi
|
|
102
|
+
try:
|
|
103
|
+
doc = gemmi.cif.read(path)
|
|
104
|
+
return any(b.find_value("_atom_site.id") is not None or b.find_loop("_atom_site.id") for b in doc)
|
|
105
|
+
except Exception:
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
main()
|
moorlight/chat.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Built-in chat: runs headless Claude Code (`claude -p`) with the moorlight MCP tools attached.
|
|
2
|
+
|
|
3
|
+
Every message is sent with a compact description of the current view (visible maps,
|
|
4
|
+
contour levels, centre, selection), so "this map" or "these atoms" resolve to what is
|
|
5
|
+
on screen. The Claude Code session is resumed between messages, so it remembers the
|
|
6
|
+
conversation; it can also be resumed from a terminal with `claude --resume <id>`.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
SYSTEM = """You are embedded in "Moorlight", a lightweight Coot-like model-building viewer used for
|
|
18
|
+
crystallographic (mostly time-resolved, TorchRef difference-refinement) work. The user sees a 3D view of
|
|
19
|
+
models and maps. Use the mcp__moorlight__* tools to inspect and change what they see:
|
|
20
|
+
- state: what is loaded/visible (maps with contour levels in sigma, centre, selected atoms, refinement status)
|
|
21
|
+
- screenshot: returns an image of the current view
|
|
22
|
+
- goto / set_map / peaks / map_correlation / density_at_atoms for analysis
|
|
23
|
+
- set_refinement_options: target "map" (2mFo-DFc-like map), "difference" (fit light-minus-dark model density
|
|
24
|
+
to a difference map such as WDF, dark reference = dark model / other altlocs / start coordinates) or "both";
|
|
25
|
+
non-bonded repulsion is off by default
|
|
26
|
+
- refine, add_altconf, rotamers/set_rotamer, mutate, add_water, add_ion, add_ligand, add_residue, renumber,
|
|
27
|
+
delete, fix_atoms, undo for building. Model edits are undoable.
|
|
28
|
+
Each user message starts with a [viewer] block describing the current view; "this map", "here" or "these
|
|
29
|
+
atoms" refer to it. Map numbers like [3] are map ids. Prefer showing the user things (goto, set_map) over
|
|
30
|
+
long explanations. When you run TorchRef refinements, write outputs to a new directory and afterwards load
|
|
31
|
+
them with load_torchref so the user can inspect them. Keep answers short; the chat panel is narrow."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _short_state(s):
|
|
35
|
+
lines = []
|
|
36
|
+
for m in s.molecules.values():
|
|
37
|
+
lines.append(f"molecule {m.id}: {m.name} ({m.path}){' [active]' if m.id == s.active_mol else ''}"
|
|
38
|
+
f"{'' if m.visible else ' [hidden]'}")
|
|
39
|
+
for x in s.maps.values():
|
|
40
|
+
if x.visible:
|
|
41
|
+
lines.append(f"map [{x.id}] {x.name} from {x.source}: shown at {x.level_sigma:.2f} sigma"
|
|
42
|
+
f"{' (+/-)' if x.kind == 'diff' else ''}{' [refinement map]' if x.id == s.refinement_map else ''}")
|
|
43
|
+
hidden = [f"[{x.id}] {x.name}" for x in s.maps.values() if not x.visible]
|
|
44
|
+
if hidden:
|
|
45
|
+
lines.append("hidden maps: " + ", ".join(hidden))
|
|
46
|
+
st = s.state()
|
|
47
|
+
v = st["view"]
|
|
48
|
+
c = v.get("center") or [0, 0, 0]
|
|
49
|
+
lines.append(f"view centre: ({c[0]:.1f}, {c[1]:.1f}, {c[2]:.1f})" +
|
|
50
|
+
(f", nearest atom {v['nearest_atom']}" if v.get("nearest_atom") else ""))
|
|
51
|
+
if v.get("selection_atoms"):
|
|
52
|
+
lines.append("selected atoms: " + ", ".join(v["selection_atoms"][:20]))
|
|
53
|
+
if st.get("refinement"):
|
|
54
|
+
lines.append(f"real-space refinement running on {st['refinement']['moving_atoms']} atoms")
|
|
55
|
+
return "\n".join(lines)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ClaudeChat:
|
|
59
|
+
def __init__(self, session, emit, claude_bin=None, allow_bash=False, model=None, port=None):
|
|
60
|
+
self.s = session
|
|
61
|
+
self.emit = emit
|
|
62
|
+
self.claude = claude_bin or shutil.which("claude") or os.path.expanduser("~/.local/bin/claude")
|
|
63
|
+
self.allow_bash = allow_bash
|
|
64
|
+
self.model = model
|
|
65
|
+
self.port = port
|
|
66
|
+
self.session_id = None
|
|
67
|
+
self.proc = None
|
|
68
|
+
self.history: list[dict] = []
|
|
69
|
+
|
|
70
|
+
def settings(self):
|
|
71
|
+
return {"allow_bash": self.allow_bash, "model": self.model, "session_id": self.session_id,
|
|
72
|
+
"claude": self.claude}
|
|
73
|
+
|
|
74
|
+
def update_settings(self, d):
|
|
75
|
+
if "allow_bash" in d:
|
|
76
|
+
self.allow_bash = bool(d["allow_bash"])
|
|
77
|
+
if "model" in d:
|
|
78
|
+
self.model = d["model"] or None
|
|
79
|
+
|
|
80
|
+
def reset(self):
|
|
81
|
+
self.session_id = None
|
|
82
|
+
self.history.clear()
|
|
83
|
+
|
|
84
|
+
def _mcp_config(self):
|
|
85
|
+
d = os.path.join(self.s.workdir, ".moorlight")
|
|
86
|
+
os.makedirs(d, exist_ok=True)
|
|
87
|
+
p = os.path.join(d, "mcp.json")
|
|
88
|
+
cfg = {"mcpServers": {"moorlight": {
|
|
89
|
+
"command": sys.executable, "args": ["-m", "moorlight.mcp", "--url", f"http://127.0.0.1:{self.port}"],
|
|
90
|
+
"env": {"PYTHONPATH": os.path.dirname(os.path.dirname(os.path.abspath(__file__)))}}}}
|
|
91
|
+
with open(p, "w") as fh:
|
|
92
|
+
json.dump(cfg, fh, indent=1)
|
|
93
|
+
return p
|
|
94
|
+
|
|
95
|
+
async def _out(self, msg):
|
|
96
|
+
self.history.append(msg)
|
|
97
|
+
await self.emit(msg)
|
|
98
|
+
|
|
99
|
+
async def stop(self):
|
|
100
|
+
if self.proc and self.proc.returncode is None:
|
|
101
|
+
self.proc.terminate()
|
|
102
|
+
|
|
103
|
+
async def send(self, text):
|
|
104
|
+
if self.proc and self.proc.returncode is None:
|
|
105
|
+
await self._out({"type": "chat", "role": "system", "text": "Claude is still busy – wait or press stop."})
|
|
106
|
+
return
|
|
107
|
+
await self._out({"type": "chat", "role": "user", "text": text})
|
|
108
|
+
prompt = f"[viewer]\n{_short_state(self.s)}\n[/viewer]\n\n{text}"
|
|
109
|
+
tools = ["mcp__moorlight", "Read", "Glob", "Grep", "TodoWrite"]
|
|
110
|
+
if self.allow_bash:
|
|
111
|
+
tools += ["Bash", "Write", "Edit"]
|
|
112
|
+
args = [self.claude, "-p", prompt, "--output-format", "stream-json", "--verbose",
|
|
113
|
+
"--include-partial-messages", "--mcp-config", self._mcp_config(),
|
|
114
|
+
"--append-system-prompt", SYSTEM, "--allowedTools", *tools]
|
|
115
|
+
if self.session_id:
|
|
116
|
+
args += ["--resume", self.session_id]
|
|
117
|
+
if self.model:
|
|
118
|
+
args += ["--model", self.model]
|
|
119
|
+
t0 = time.time()
|
|
120
|
+
try:
|
|
121
|
+
self.proc = await asyncio.create_subprocess_exec(
|
|
122
|
+
*args, cwd=self.s.workdir, stdin=asyncio.subprocess.DEVNULL,
|
|
123
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, limit=16 * 1024 * 1024)
|
|
124
|
+
except FileNotFoundError:
|
|
125
|
+
await self._out({"type": "chat", "role": "system", "text": f"claude CLI not found at {self.claude}"})
|
|
126
|
+
return
|
|
127
|
+
await self.emit({"type": "chat_busy", "busy": True})
|
|
128
|
+
streamed = False
|
|
129
|
+
try:
|
|
130
|
+
async for raw in self.proc.stdout:
|
|
131
|
+
try:
|
|
132
|
+
ev = json.loads(raw)
|
|
133
|
+
except json.JSONDecodeError:
|
|
134
|
+
continue
|
|
135
|
+
et = ev.get("type")
|
|
136
|
+
if et == "system" and ev.get("subtype") == "init":
|
|
137
|
+
self.session_id = ev.get("session_id", self.session_id)
|
|
138
|
+
elif et == "stream_event":
|
|
139
|
+
e = ev.get("event", {})
|
|
140
|
+
if e.get("type") == "content_block_delta" and e.get("delta", {}).get("type") == "text_delta":
|
|
141
|
+
streamed = True
|
|
142
|
+
await self.emit({"type": "chat_delta", "text": e["delta"]["text"]})
|
|
143
|
+
elif e.get("type") == "message_start":
|
|
144
|
+
await self.emit({"type": "chat_delta_start"})
|
|
145
|
+
elif et == "assistant":
|
|
146
|
+
for block in ev.get("message", {}).get("content", []):
|
|
147
|
+
if block.get("type") == "text" and block.get("text", "").strip():
|
|
148
|
+
await self._out({"type": "chat", "role": "assistant", "text": block["text"],
|
|
149
|
+
"streamed": streamed})
|
|
150
|
+
elif block.get("type") == "tool_use":
|
|
151
|
+
name = block.get("name", "").replace("mcp__moorlight__", "")
|
|
152
|
+
if name == "ToolSearch":
|
|
153
|
+
continue
|
|
154
|
+
await self._out({"type": "chat", "role": "tool", "text": name,
|
|
155
|
+
"input": json.dumps(block.get("input", {}))[:300]})
|
|
156
|
+
streamed = False
|
|
157
|
+
elif et == "user":
|
|
158
|
+
for block in ev.get("message", {}).get("content", []) or []:
|
|
159
|
+
if isinstance(block, dict) and block.get("type") == "tool_result" and block.get("is_error"):
|
|
160
|
+
c = block.get("content")
|
|
161
|
+
c = c if isinstance(c, str) else json.dumps(c)[:300]
|
|
162
|
+
await self._out({"type": "chat", "role": "tool_error", "text": c[:300]})
|
|
163
|
+
elif et == "result":
|
|
164
|
+
self.session_id = ev.get("session_id", self.session_id)
|
|
165
|
+
if ev.get("is_error"):
|
|
166
|
+
await self._out({"type": "chat", "role": "system", "text": str(ev.get("result"))[:500]})
|
|
167
|
+
cost = ev.get("total_cost_usd")
|
|
168
|
+
await self.emit({"type": "chat_done", "seconds": round(time.time() - t0, 1),
|
|
169
|
+
"cost": cost, "session_id": self.session_id})
|
|
170
|
+
await self.proc.wait()
|
|
171
|
+
if self.proc.returncode not in (0, None, -15):
|
|
172
|
+
err = (await self.proc.stderr.read()).decode()[-800:]
|
|
173
|
+
await self._out({"type": "chat", "role": "system", "text": f"claude exited {self.proc.returncode}: {err}"})
|
|
174
|
+
finally:
|
|
175
|
+
await self.emit({"type": "chat_busy", "busy": False})
|
moorlight/dicts.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Restraint dictionaries embedded in refinement output (phenix mmCIF data_comp_XXX blocks).
|
|
2
|
+
|
|
3
|
+
They are rewritten in the minimal CCP4 layout TorchRef parses reliably: primed atom names
|
|
4
|
+
double-quoted, only atom/bond/angle/tor/plane/chir loops kept (descriptor loops break the reader).
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
import gemmi
|
|
11
|
+
|
|
12
|
+
KEEP = ["_chem_comp_atom.", "_chem_comp_bond.", "_chem_comp_angle.", "_chem_comp_tor.",
|
|
13
|
+
"_chem_comp_plane_atom.", "_chem_comp_chir."]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _q(v):
|
|
17
|
+
v = gemmi.cif.as_string(v)
|
|
18
|
+
return f'"{v}"' if ("'" in v or " " in v or v == "") else v
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def comp_ids(path):
|
|
22
|
+
try:
|
|
23
|
+
doc = gemmi.cif.read(path)
|
|
24
|
+
except Exception:
|
|
25
|
+
return []
|
|
26
|
+
return [b.name[5:] for b in doc if b.name.startswith("comp_") and b.name != "comp_list"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def extract_all(path, outdir):
|
|
30
|
+
"""Write every data_comp_XXX block of a CIF as its own TorchRef-safe dictionary. Returns paths."""
|
|
31
|
+
doc = gemmi.cif.read(path)
|
|
32
|
+
groups = {}
|
|
33
|
+
for b in doc:
|
|
34
|
+
for row in b.find("_chem_comp.", ["id", "group"]):
|
|
35
|
+
groups[gemmi.cif.as_string(row[0])] = gemmi.cif.as_string(row[1])
|
|
36
|
+
os.makedirs(outdir, exist_ok=True)
|
|
37
|
+
out = []
|
|
38
|
+
for b in doc:
|
|
39
|
+
if not b.name.startswith("comp_") or b.name == "comp_list":
|
|
40
|
+
continue
|
|
41
|
+
comp = b.name[5:]
|
|
42
|
+
atoms = b.find("_chem_comp_atom.", ["atom_id", "type_symbol"])
|
|
43
|
+
if not len(atoms) or not b.find_mmcif_category("_chem_comp_bond."):
|
|
44
|
+
continue # single atoms (ions) need no dictionary, and TorchRef rejects bond-less files
|
|
45
|
+
nh = sum(1 for r in atoms if gemmi.cif.as_string(r[1]).upper() != "H")
|
|
46
|
+
group = groups.get(comp, "non-polymer") or "non-polymer"
|
|
47
|
+
lines = ["data_comp_list", "loop_", "_chem_comp.id", "_chem_comp.three_letter_code", "_chem_comp.name",
|
|
48
|
+
"_chem_comp.group", "_chem_comp.number_atoms_all", "_chem_comp.number_atoms_nh",
|
|
49
|
+
"_chem_comp.desc_level", f"{comp} {comp} {comp} {_q(group)} {len(atoms)} {nh} .", "",
|
|
50
|
+
f"data_comp_{comp}"]
|
|
51
|
+
for pre in KEEP:
|
|
52
|
+
tab = b.find_mmcif_category(pre)
|
|
53
|
+
if not tab:
|
|
54
|
+
continue
|
|
55
|
+
lines.append("loop_")
|
|
56
|
+
lines += [t for t in tab.tags]
|
|
57
|
+
for r in tab:
|
|
58
|
+
lines.append(" ".join(_q(r[i]) for i in range(len(r))))
|
|
59
|
+
p = os.path.join(outdir, f"{comp}.cif")
|
|
60
|
+
with open(p, "w") as fh:
|
|
61
|
+
fh.write("\n".join(lines) + "\n")
|
|
62
|
+
out.append(p)
|
|
63
|
+
return out
|