chartremotely 0.2.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.
@@ -0,0 +1,101 @@
1
+ """Keep a patron's Nostr key for the patron, never for this agent.
2
+
3
+ A key made by ``setup`` belongs to the human. It goes into their login
4
+ Keychain as an internet password for the ChartRemotely site, and it is
5
+ handed to Safari once, through the clipboard, so Safari can save it to
6
+ iCloud Passwords where their other devices and apps find it. A command-line
7
+ tool cannot write a synchronizable Keychain item itself (that needs an
8
+ entitled app: ``errSecMissingEntitlement``), which is why Safari does the
9
+ syncing.
10
+
11
+ Nothing here writes the key to the agent's config, a log, a URL or a
12
+ command line. It travels as ``kSecValueData`` or on a pipe, and nowhere else.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import subprocess
18
+ import threading
19
+
20
+ SERVER = "chartremotely.tollbooth-dpyc.com"
21
+ LABEL = "ChartRemotely — Nostr key"
22
+ CLIPBOARD_SECONDS = 60
23
+
24
+ ERR_DUPLICATE = -25299
25
+ ERR_NOT_FOUND = -25300
26
+
27
+
28
+ class KeystoreError(RuntimeError):
29
+ """The Keychain refused, and says why by its status code."""
30
+
31
+
32
+ def _security():
33
+ import Security # lazy: macOS only
34
+
35
+ return Security
36
+
37
+
38
+ def item_query(npub: str) -> dict:
39
+ """The attributes that name one saved key. The secret is never among them."""
40
+ S = _security()
41
+ return {S.kSecClass: S.kSecClassInternetPassword,
42
+ S.kSecAttrServer: SERVER,
43
+ S.kSecAttrAccount: npub}
44
+
45
+
46
+ def save(npub: str, nsec: str) -> None:
47
+ """Store (or replace) the patron's key in their login Keychain."""
48
+ S = _security()
49
+ secret = nsec.encode()
50
+ status = S.SecItemAdd({**item_query(npub), S.kSecAttrLabel: LABEL, S.kSecValueData: secret}, None)[0]
51
+ if status == ERR_DUPLICATE:
52
+ status = S.SecItemUpdate(item_query(npub), {S.kSecValueData: secret})
53
+ if status != 0:
54
+ raise KeystoreError(f"the Keychain refused to save the key (status {status})")
55
+
56
+
57
+ def saved_npubs() -> list[str]:
58
+ """npubs with a key saved for this site. Reads names only, never secrets."""
59
+ S = _security()
60
+ query = {S.kSecClass: S.kSecClassInternetPassword, S.kSecAttrServer: SERVER,
61
+ S.kSecReturnAttributes: True, S.kSecMatchLimit: S.kSecMatchLimitAll}
62
+ status, found = S.SecItemCopyMatching(query, None)
63
+ if status == ERR_NOT_FOUND or not found:
64
+ return []
65
+ if status != 0:
66
+ raise KeystoreError(f"the Keychain could not be read (status {status})")
67
+ return sorted(str(item.get(S.kSecAttrAccount) or "") for item in found if item.get(S.kSecAttrAccount))
68
+
69
+
70
+ def load(npub: str) -> str:
71
+ """The saved key for one npub. macOS asks the human before it answers."""
72
+ S = _security()
73
+ status, data = S.SecItemCopyMatching({**item_query(npub), S.kSecReturnData: True}, None)
74
+ if status != 0 or data is None:
75
+ raise KeystoreError(f"no key saved for {npub} (status {status})")
76
+ return bytes(data).decode()
77
+
78
+
79
+ # -- the clipboard, for Safari's Save password prompt --------------------------
80
+
81
+ def copy(secret: str) -> None:
82
+ """Put a secret on the clipboard over a pipe, never on a command line."""
83
+ subprocess.run(["pbcopy"], input=secret.encode(), check=True)
84
+
85
+
86
+ def clear_if_unchanged(secret: str) -> bool:
87
+ """Empty the clipboard, but only if it still holds this secret."""
88
+ current = subprocess.run(["pbpaste"], capture_output=True, check=False).stdout.decode()
89
+ if current != secret:
90
+ return False
91
+ subprocess.run(["pbcopy"], input=b"", check=True)
92
+ return True
93
+
94
+
95
+ def copy_briefly(secret: str, seconds: float = CLIPBOARD_SECONDS) -> threading.Timer:
96
+ """Copy a secret and clear it again after ``seconds``, unless something replaced it."""
97
+ copy(secret)
98
+ timer = threading.Timer(seconds, clear_if_unchanged, args=(secret,))
99
+ timer.daemon = True
100
+ timer.start()
101
+ return timer
@@ -0,0 +1,106 @@
1
+ """Find controls without assuming where on screen anything lives.
2
+
3
+ thinkorswim layouts differ per user, and one user's layout differs through
4
+ the day - charts get rearranged, panes resized, grids switched. Anything
5
+ that sweeps a fixed screen region works on the machine it was written on
6
+ and nowhere else.
7
+
8
+ The accessibility tree is no help on its own: the chart's controls are not
9
+ reachable through ``AXChildren``. The combo box holding the symbol reports
10
+ the window as its parent, yet the window does not list it as a child. The
11
+ link exists upward but not downward, so hit-testing is the only way in.
12
+
13
+ What the tree *does* expose reliably is the container layout - the split
14
+ groups and tab groups that tile the window. So: ask the tree where the
15
+ panes are, then hit-test relative to each pane's own bounds. Move the chart
16
+ anywhere and the search follows it.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from . import ax
22
+
23
+ # Panes smaller than this cannot be a chart.
24
+ MIN_PANE_W, MIN_PANE_H = 400, 300
25
+
26
+ # Charting apps put the symbol entry and the toolbar in a band at the top of
27
+ # the pane. Searching that band first is an optimisation, not an assumption:
28
+ # discovery falls back to the whole pane.
29
+ BAND_HEIGHT = 140
30
+ STEP_X, STEP_Y = 12, 6
31
+
32
+ CONTAINER_ROLES = ("AXTabGroup", "AXSplitGroup", "AXScrollArea", "AXGroup")
33
+
34
+
35
+ def panes(ax_app, window_prefix: str) -> list[tuple[int, int, int, int]]:
36
+ """Candidate chart panes as (x, y, w, h), largest first.
37
+
38
+ Derived entirely from what the tree reports, so it holds wherever the
39
+ user has dragged things.
40
+ """
41
+ window = ax.window(ax_app, window_prefix)
42
+ if window is None:
43
+ return []
44
+ found: set[tuple[int, int, int, int]] = set()
45
+
46
+ def visit(element):
47
+ if ax.attr(element, "AXRole") not in CONTAINER_ROLES:
48
+ return
49
+ p, s = ax.position(element), ax.size(element)
50
+ if p and s and s.width >= MIN_PANE_W and s.height >= MIN_PANE_H:
51
+ found.add((int(p.x), int(p.y), int(s.width), int(s.height)))
52
+
53
+ ax.walk(window, visit, budget=[400000])
54
+ # Whole-window fallback: a layout may expose no qualifying container.
55
+ p, s = ax.position(window), ax.size(window)
56
+ if p and s:
57
+ found.add((int(p.x), int(p.y), int(s.width), int(s.height)))
58
+ return sorted(found, key=lambda r: -(r[2] * r[3]))
59
+
60
+
61
+ def scan(ax_app, rect, matches, *, band: int | None = BAND_HEIGHT,
62
+ step_x: int = STEP_X, step_y: int = STEP_Y) -> tuple[int, int] | None:
63
+ """Hit-test inside a rect until ``matches(element)`` is true.
64
+
65
+ Coordinates are derived from the rect, never hardcoded. ``band`` limits
66
+ the search to the top of the pane; pass None to sweep all of it.
67
+ """
68
+ x0, y0, w, h = rect
69
+ height = min(band, h) if band else h
70
+ for y in range(y0 + 2, y0 + height, step_y):
71
+ for x in range(x0 + 2, x0 + w, step_x):
72
+ if matches(ax.element_at(ax_app, x, y)):
73
+ return x, y
74
+ return None
75
+
76
+
77
+ def scan_all(ax_app, rect, matches, *, band: int | None = BAND_HEIGHT,
78
+ step_x: int = STEP_X, step_y: int = STEP_Y) -> list[tuple[int, int]]:
79
+ """Every distinct match in a rect, de-duplicated by element bounds.
80
+
81
+ Used where a window may hold several charts: each gets its own hit.
82
+ """
83
+ x0, y0, w, h = rect
84
+ height = min(band, h) if band else h
85
+ hits: list[tuple[int, int]] = []
86
+ seen: set[tuple[int, int]] = set()
87
+ for y in range(y0 + 2, y0 + height, step_y):
88
+ for x in range(x0 + 2, x0 + w, step_x):
89
+ element = ax.element_at(ax_app, x, y)
90
+ if not matches(element):
91
+ continue
92
+ p = ax.position(element)
93
+ key = (int(p.x), int(p.y)) if p else (x, y)
94
+ if key not in seen:
95
+ seen.add(key)
96
+ hits.append((x, y))
97
+ return hits
98
+
99
+
100
+ def find_in_panes(ax_app, window_prefix: str, matches, *, band: int | None = BAND_HEIGHT):
101
+ """Search each pane in turn, largest first. Returns (x, y) or None."""
102
+ for rect in panes(ax_app, window_prefix):
103
+ hit = scan(ax_app, rect, matches, band=band)
104
+ if hit:
105
+ return hit
106
+ return None
@@ -0,0 +1,98 @@
1
+ """Just enough of an MCP client to call three operator tools.
2
+
3
+ ``setup`` proves who the patron is and pairs this machine, which means
4
+ calling ``chart_request_npub_proof``, ``chart_receive_npub_proof`` and
5
+ ``chart_pair_agent``. A full MCP SDK would be the only reason this agent
6
+ grew a dependency tree, so this speaks the streamable-HTTP transport
7
+ directly: initialize, the initialized notification, then ``tools/call``,
8
+ reading JSON or server-sent-event answers and carrying the session header.
9
+
10
+ Stdlib only, importable anywhere.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import itertools
16
+ import json
17
+ import urllib.request
18
+
19
+ PROTOCOL_VERSION = "2025-06-18"
20
+
21
+
22
+ class McpError(RuntimeError):
23
+ """The operator could not be reached, or answered with an error."""
24
+
25
+
26
+ def parse_body(raw: str) -> dict:
27
+ """One JSON-RPC message, from a JSON body or the last SSE ``data:`` line."""
28
+ raw = raw.strip()
29
+ if not raw:
30
+ return {}
31
+ if raw.startswith("{"):
32
+ return json.loads(raw)
33
+ data = [line[5:].strip() for line in raw.splitlines() if line.startswith("data:")]
34
+ if not data:
35
+ raise McpError("the operator sent an answer this client cannot read")
36
+ return json.loads(data[-1])
37
+
38
+
39
+ def tool_payload(result: dict) -> dict:
40
+ """What a tool answered: its structured content, else its first text block as JSON."""
41
+ if isinstance(result.get("structuredContent"), dict):
42
+ return result["structuredContent"]
43
+ for block in result.get("content") or []:
44
+ if block.get("type") == "text":
45
+ try:
46
+ parsed = json.loads(block.get("text") or "")
47
+ except ValueError:
48
+ return {"text": block.get("text")}
49
+ return parsed if isinstance(parsed, dict) else {"value": parsed}
50
+ return {}
51
+
52
+
53
+ class Client:
54
+ """A session with one operator's ``/mcp`` endpoint."""
55
+
56
+ def __init__(self, base_url: str, timeout: float = 60.0) -> None:
57
+ self.url = base_url.rstrip("/") + "/mcp"
58
+ self.timeout = timeout
59
+ self.session: str | None = None
60
+ self._ids = itertools.count(1)
61
+
62
+ def _send(self, message: dict) -> dict:
63
+ headers = {"Content-Type": "application/json",
64
+ "Accept": "application/json, text/event-stream"}
65
+ if self.session:
66
+ headers["mcp-session-id"] = self.session
67
+ request = urllib.request.Request(
68
+ self.url, data=json.dumps(message).encode(), headers=headers, method="POST")
69
+ try:
70
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
71
+ self.session = response.headers.get("mcp-session-id") or self.session
72
+ return parse_body(response.read().decode("utf-8", "replace"))
73
+ except OSError as exc:
74
+ raise McpError(f"could not reach the operator: {exc}") from None
75
+
76
+ def _start(self) -> None:
77
+ if self.session:
78
+ return
79
+ answer = self._send({
80
+ "jsonrpc": "2.0", "id": next(self._ids), "method": "initialize",
81
+ "params": {"protocolVersion": PROTOCOL_VERSION, "capabilities": {},
82
+ "clientInfo": {"name": "chartremotely-setup", "version": "1"}}})
83
+ if "error" in answer:
84
+ raise McpError(str(answer["error"].get("message", answer["error"])))
85
+ self._send({"jsonrpc": "2.0", "method": "notifications/initialized"})
86
+
87
+ def call(self, tool: str, arguments: dict) -> dict:
88
+ """Call a tool and return what it answered. Raises McpError on a protocol error."""
89
+ self._start()
90
+ answer = self._send({"jsonrpc": "2.0", "id": next(self._ids), "method": "tools/call",
91
+ "params": {"name": tool, "arguments": arguments}})
92
+ if "error" in answer:
93
+ raise McpError(str(answer["error"].get("message", answer["error"])))
94
+ result = answer.get("result") or {}
95
+ payload = tool_payload(result)
96
+ if result.get("isError"):
97
+ raise McpError(payload.get("text") or json.dumps(payload) or "the tool failed")
98
+ return payload
chartremotely/push.py ADDED
@@ -0,0 +1,58 @@
1
+ """Tell the operator what the chart looks like after it changes.
2
+
3
+ A chart changed by voice goes straight to this machine over the tailnet, so
4
+ the operator never hears of it. Right after a change succeeds, one picture of
5
+ the chart pane is pushed up so the patron's browser can offer it. The operator
6
+ keeps only the newest, encrypted, for an hour.
7
+
8
+ Off the voice path on purpose: the push runs in the background after the
9
+ answer is already on its way, and any failure is swallowed. The chart has
10
+ already changed; a missing picture must never turn into a spoken error.
11
+
12
+ Stdlib only at module scope, like the relay: the capture is imported lazily.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import threading
18
+ import time
19
+
20
+ from . import config, relay
21
+
22
+ #: Time for the chart to redraw before its picture is taken.
23
+ SETTLE_SECONDS = 1.0
24
+
25
+ _lock = threading.Lock()
26
+
27
+
28
+ def payload(cfg: dict, image: str) -> tuple[str, dict] | None:
29
+ """Where to send a picture and what to send, or None when not paired."""
30
+ base = (cfg.get("operator_url") or "").rstrip("/")
31
+ agent_id, secret = cfg.get("agent_id"), cfg.get("agent_secret")
32
+ if not (base and agent_id and secret):
33
+ return None
34
+ return f"{base}/agent/snapshot", {"agent_id": agent_id, "secret": secret, "image": image}
35
+
36
+
37
+ def after_change(reply: str) -> str:
38
+ """Start a picture push if the change worked. Returns the reply unchanged."""
39
+ if not reply.startswith("ERR"):
40
+ threading.Thread(target=push_now, name="push-latest", daemon=True).start()
41
+ return reply
42
+
43
+
44
+ def push_now() -> None:
45
+ """Take the chart's picture and send it. Never raises."""
46
+ try:
47
+ cfg = config.load()
48
+ if payload(cfg, "") is None:
49
+ return
50
+ # One at a time: two quick changes should not capture over each other.
51
+ with _lock:
52
+ time.sleep(SETTLE_SECONDS)
53
+ from . import snapshot, window
54
+ image = snapshot.as_reply(snapshot.shrink(window.capture()))
55
+ url, body = payload(cfg, image)
56
+ relay._post(url, body, timeout=30)
57
+ except Exception: # noqa: BLE001, S110 - deliberate: see the module docstring
58
+ pass
@@ -0,0 +1,53 @@
1
+ """The SEC company registry, cached locally.
2
+
3
+ Roughly 10,500 US issuers including ETFs. Ordered by prominence in the
4
+ source file, which makes the index a usable tie-breaker: NVDA, AAPL and
5
+ GOOGL are the first three entries.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import time
12
+ import urllib.request
13
+
14
+ from .config import DATA_DIR
15
+
16
+ SEC_URL = "https://www.sec.gov/files/company_tickers.json"
17
+ CACHE = DATA_DIR / "tickers.json"
18
+ MAX_AGE = 7 * 24 * 3600
19
+ # The SEC requires a User-Agent carrying a contact address and answers 403
20
+ # without one. A repository URL alone is not enough.
21
+ DEFAULT_CONTACT = "chartremotely@example.com"
22
+
23
+
24
+ def _user_agent() -> str:
25
+ from .config import load
26
+ contact = load().get("contact") or DEFAULT_CONTACT
27
+ # SEC's documented format is plain "Name email". Anything fancier is
28
+ # rejected: a User-Agent carrying a URL answers 403, as does one with no
29
+ # contact address at all.
30
+ return f"ChartRemotely {contact}"
31
+
32
+
33
+ def _fresh() -> bool:
34
+ return CACHE.exists() and time.time() - CACHE.stat().st_mtime < MAX_AGE
35
+
36
+
37
+ def refresh() -> list[dict]:
38
+ request = urllib.request.Request(SEC_URL, headers={"User-Agent": _user_agent()})
39
+ with urllib.request.urlopen(request, timeout=20) as response:
40
+ raw = json.load(response)
41
+ rows = [{"t": v["ticker"], "n": v["title"], "r": rank}
42
+ for rank, v in enumerate(raw.values())]
43
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
44
+ tmp = CACHE.with_suffix(".tmp")
45
+ tmp.write_text(json.dumps(rows))
46
+ tmp.replace(CACHE)
47
+ return rows
48
+
49
+
50
+ def load(force: bool = False) -> list[dict]:
51
+ if force or not _fresh():
52
+ return refresh()
53
+ return json.loads(CACHE.read_text())
chartremotely/relay.py ADDED
@@ -0,0 +1,145 @@
1
+ """Talk to the operator, so a display can be driven from anywhere.
2
+
3
+ The agent dials OUT and holds a poll open. Nothing here ever listens on a
4
+ public port and the operator never connects to this machine: no Funnel, no
5
+ port forwarding, no firewall rules, and nothing on the patron's network
6
+ becomes reachable. That single choice is why pairing is safe to offer to
7
+ someone who is not a network engineer.
8
+
9
+ Stdlib only, on purpose. This runs on a machine whose owner granted
10
+ Accessibility permission to software that types into a trading
11
+ application - the dependency list is part of what they are auditing.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import time
18
+ import urllib.error
19
+ import urllib.request
20
+
21
+ from . import config
22
+
23
+ #: Poll window. The operator holds a request open for slightly less than
24
+ #: this, so a timeout here means the network dropped it, not that the
25
+ #: operator is idle.
26
+ POLL_TIMEOUT = 35.0
27
+ CLAIM_INTERVAL = 3.0
28
+ BACKOFF_MAX = 60.0
29
+
30
+
31
+ class PairingError(RuntimeError):
32
+ """Pairing could not be completed."""
33
+
34
+
35
+ def execute(command: str) -> str:
36
+ """Hand a relayed command to the agent's own vocabulary.
37
+
38
+ Imported lazily and wrapped here for one reason: the wire logic in this
39
+ module is pure, and importing vocab at module scope would drag the macOS
40
+ drivers in with it - making the whole file unimportable anywhere the
41
+ drivers are not installed, CI included.
42
+ """
43
+ from .vocab import dispatch
44
+ return dispatch(command)
45
+
46
+
47
+ def _post(url: str, payload: dict, timeout: float) -> dict:
48
+ body = json.dumps(payload).encode()
49
+ request = urllib.request.Request(
50
+ url, data=body, method="POST",
51
+ headers={"Content-Type": "application/json"})
52
+ with urllib.request.urlopen(request, timeout=timeout) as response:
53
+ raw = response.read().decode("utf-8", "replace")
54
+ return json.loads(raw) if raw.strip() else {}
55
+
56
+
57
+ def open_code(base: str) -> tuple[str, float]:
58
+ """Ask the operator for a pairing code. Returns the code and its lifetime in seconds."""
59
+ opened = _post(f"{base}/agent/open", {}, timeout=20)
60
+ code = opened.get("code")
61
+ if not code:
62
+ raise PairingError("the operator did not issue a pairing code")
63
+ return code, float(opened.get("expires_in") or 900)
64
+
65
+
66
+ def collect(base: str, code: str, expires_in: float) -> dict:
67
+ """Wait until someone adopts the code, then keep the identity it earns."""
68
+ deadline = time.time() + expires_in
69
+ while time.time() < deadline:
70
+ time.sleep(CLAIM_INTERVAL)
71
+ try:
72
+ claimed = _post(f"{base}/agent/collect", {"code": code}, timeout=45)
73
+ except (urllib.error.URLError, TimeoutError, OSError):
74
+ # A read timeout is TimeoutError, not URLError - catching only
75
+ # the latter turned a slow first response into a hard failure.
76
+ # The code stays valid, and collect is idempotent, so retrying
77
+ # is always safe.
78
+ continue
79
+ if claimed.get("paired"):
80
+ return config.update(operator_url=base,
81
+ agent_id=claimed["agent_id"],
82
+ agent_secret=claimed["secret"])
83
+ raise PairingError("the pairing code expired before anyone claimed it")
84
+
85
+
86
+ def operator_base(operator_url: str | None) -> str:
87
+ base = (operator_url or config.load().get("operator_url") or "").rstrip("/")
88
+ if not base:
89
+ raise PairingError("no operator URL; pass one or set operator_url in config")
90
+ return base
91
+
92
+
93
+ def pair(operator_url: str | None = None, on_code=print) -> dict:
94
+ """Ask the operator for a code, show it, and wait to be adopted.
95
+
96
+ The patron reads the code to their MCP client, which calls
97
+ ``pair_agent``. ``setup`` takes the same path and adopts the code itself.
98
+ This machine hands out nothing: it proves it is theirs and receives an
99
+ identity in return.
100
+ """
101
+ base = operator_base(operator_url)
102
+ code, expires_in = open_code(base)
103
+ on_code(code)
104
+ return collect(base, code, expires_in)
105
+
106
+
107
+ def run(operator_url: str | None = None, once: bool = False) -> None:
108
+ """Hold a poll open, execute what arrives, report the result.
109
+
110
+ Commands are handed straight to the agent's own vocabulary. The
111
+ operator relays; this machine decides what is permitted, which is what
112
+ keeps a compromised operator from widening the surface.
113
+ """
114
+ cfg = config.load()
115
+ base = (operator_url or cfg.get("operator_url") or "").rstrip("/")
116
+ agent_id, secret = cfg.get("agent_id"), cfg.get("agent_secret")
117
+ if not (base and agent_id and secret):
118
+ raise PairingError("this agent is not paired; run: chartremotely pair")
119
+
120
+ credentials = {"agent_id": agent_id, "secret": secret}
121
+ backoff = 1.0
122
+ while True:
123
+ try:
124
+ envelope = _post(f"{base}/agent/poll", credentials, timeout=POLL_TIMEOUT)
125
+ backoff = 1.0
126
+ except (urllib.error.URLError, TimeoutError, OSError):
127
+ # The operator being unreachable is not fatal: this machine
128
+ # still works locally, and the display should reconnect by
129
+ # itself when the operator returns.
130
+ time.sleep(backoff)
131
+ backoff = min(backoff * 2, BACKOFF_MAX)
132
+ if once:
133
+ return
134
+ continue
135
+
136
+ if envelope.get("command"):
137
+ reply = execute(str(envelope["command"]))
138
+ try:
139
+ _post(f"{base}/agent/result",
140
+ {**credentials, "id": envelope.get("id"), "reply": reply},
141
+ timeout=20)
142
+ except (urllib.error.URLError, TimeoutError, OSError):
143
+ pass # the caller has already timed out and refunded
144
+ if once:
145
+ return