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 ADDED
@@ -0,0 +1,3 @@
1
+ """Moorlight: Coot 0.9-style model building in the browser with TorchRef real-space refinement."""
2
+
3
+ __version__ = "0.1.0"
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