sppoky
0

Configure Feed

Select the types of activity you want to include in your feed.

glossa / glossa_loop.py
48 kB 1231 lines
1#!/usr/bin/env python3 2""" 3glossa_loop.py — Continuous closed loop daemon 4 5Watches for pen-idle signal from xovi extension, then: 6 capture → OCR → LLM → render → inject → repeat 7 8Usage: 9 python glossa_loop.py [--model MODEL] [--debounce SECONDS] 10 11The xovi extension writes /tmp/glossa_idle when the pen has been 12up for 2 seconds. This daemon watches that file via a persistent SSH 13connection, pulls the framebuffer only when idle is detected, and 14runs the full pipeline. 15""" 16 17import argparse 18import base64 19import hashlib 20import io 21import json 22import os 23import re 24import subprocess 25import sys 26import tempfile 27import time 28from pathlib import Path 29 30import requests 31from dotenv import load_dotenv 32 33load_dotenv() 34 35 36# ─── Persistent device socket (uinjectd) ─────────────────────────── 37import socket as _socket 38import threading as _threading 39import queue as _queue 40 41_inject_verbose = False # set from args.verbose in main() 42 43 44def _vprint(*args, **kwargs): 45 """Print only when running with -v/--verbose.""" 46 if _inject_verbose: 47 print(*args, **kwargs) 48 49 50DEVICE_HOST = "10.11.99.1" 51DEVICE_PORT = 9999 52 53 54class DeviceClient: 55 """One persistent TCP connection to uinjectd on the reMarkable. 56 57 Replaces per-call SSH for injection and per-call polling for idle 58 detection. Commands and pushed events share the same socket: 59 - Commands (draw/erase/ping) get a matching {"resp":...} reply. 60 - Events ({"event":"idle",...}) are pushed unsolicited and land 61 on the events queue. 62 63 A background reader thread demuxes the two: responses go to a 64 one-slot reply box, events go to the queue. 65 """ 66 67 def __init__(self, host=DEVICE_HOST, port=DEVICE_PORT): 68 self.host = host 69 self.port = port 70 self.sock = None 71 self.events = _queue.Queue() 72 self._reply = _queue.Queue(maxsize=1) 73 self._send_lock = _threading.Lock() 74 self._buf = b"" 75 self._reader = None 76 self._connected = False 77 78 def connect(self, timeout=10): 79 self.sock = _socket.create_connection((self.host, self.port), timeout=timeout) 80 self.sock.setsockopt(_socket.IPPROTO_TCP, _socket.TCP_NODELAY, 1) 81 self.sock.settimeout(None) 82 self._connected = True 83 self._reader = _threading.Thread(target=self._read_loop, daemon=True) 84 self._reader.start() 85 # Heartbeat: the daemon drops a client that sends nothing for 15s 86 # (its SO_RCVTIMEO). Pushed idle events flow daemon->client and don't 87 # count as client activity, so we must send a ping periodically even 88 # while idle, or the daemon silently disconnects us mid-wait. 89 self._hb = _threading.Thread(target=self._heartbeat_loop, daemon=True) 90 self._hb.start() 91 print(f"[device] Connected to uinjectd at {self.host}:{self.port}") 92 93 def _heartbeat_loop(self): 94 """Send a ping every 5s so the daemon's read-timeout never fires.""" 95 while self._connected: 96 time.sleep(5) 97 if not self._connected: 98 break 99 try: 100 self.sock.sendall(b'{"cmd":"ping"}\n') 101 except OSError: 102 self._connected = False 103 break 104 105 def _read_loop(self): 106 """Demux framed JSON lines into responses vs pushed events.""" 107 while self._connected: 108 try: 109 chunk = self.sock.recv(4096) 110 except OSError: 111 break 112 if not chunk: 113 break 114 self._buf += chunk 115 while b"\n" in self._buf: 116 line, self._buf = self._buf.split(b"\n", 1) 117 line = line.strip() 118 if not line: 119 continue 120 try: 121 msg = json.loads(line.decode("utf-8")) 122 except (json.JSONDecodeError, UnicodeDecodeError): 123 continue 124 if "event" in msg: 125 self.events.put(msg) 126 elif "resp" in msg: 127 # Heartbeat ping replies are fire-and-forget; dropping 128 # them keeps the one-slot reply box clear for the real 129 # command (draw/erase) that command() is waiting on. 130 if msg.get("resp") == "ping": 131 continue 132 if self._reply.full(): 133 try: 134 self._reply.get_nowait() 135 except _queue.Empty: 136 pass 137 self._reply.put(msg) 138 self._connected = False 139 print("[device] Connection closed") 140 141 def command(self, cmd, file_path=None, speed_ms=5, timeout=120): 142 """Send a command and wait for its matching response.""" 143 obj = {"cmd": cmd} 144 if file_path is not None: 145 obj["file"] = file_path 146 obj["speed"] = speed_ms 147 payload = (json.dumps(obj) + "\n").encode() 148 with self._send_lock: 149 # Clear any leftover reply before sending 150 try: 151 self._reply.get_nowait() 152 except _queue.Empty: 153 pass 154 self.sock.sendall(payload) 155 try: 156 return self._reply.get(timeout=timeout) 157 except _queue.Empty: 158 return {"ok": False, "error": "response timeout"} 159 160 def close(self): 161 self._connected = False 162 if self.sock: 163 try: 164 self.sock.close() 165 except OSError: 166 pass 167 168 169# Module-level handle so run_inject and idle waiting can reach the client. 170_device = None 171 172 173def run_inject(ssh, cmd, file_path, speed_ms=5, timeout=120): 174 """Send a draw/erase/swipe command to uinjectd over the persistent socket. 175 176 `cmd` is "draw", "erase", or "swipe". For "swipe", pass file_path=None. 177 Returns the daemon's response dict ({"ok": bool, ...}). Injection is 178 serialized inside the daemon, so no host-side lock is needed. 179 """ 180 if _device is None or not _device._connected: 181 return {"ok": False, "error": "device not connected"} 182 return _device.command(cmd, file_path, speed_ms=speed_ms, timeout=timeout) 183 184 185def ensure_daemon(ssh, verbose=False): 186 """Make sure uinjectd is running on the device. 187 188 Always kills and restarts to avoid stale connection state. The daemon 189 is lightweight (~50KB RSS) and starts in <100ms, so this is cheap. 190 A stale daemon can hold the TCP port while being unable to serve new 191 clients (e.g., stuck in read() from a dead session), and probing for 192 liveness creates its own race with the single-client accept loop. 193 """ 194 vflag = "-v " if verbose else "" 195 ssh.run( 196 f"killall uinjectd 2>/dev/null; sleep 0.3; " 197 f"nohup /home/root/uinjectd {vflag}> /tmp/uinjectd.log 2>&1 &", 198 timeout=5, 199 ) 200 # Give it a moment to bind the socket. 201 for _ in range(10): 202 time.sleep(0.2) 203 check = ssh.run( 204 "netstat -ln 2>/dev/null | grep -q ':9999 ' && echo up || echo down", 205 timeout=5, 206 ) 207 if "up" in (check.stdout or ""): 208 print("[device] uinjectd started") 209 return 210 raise RuntimeError("uinjectd failed to start on device") 211 212 213# ─── Persistent SSH session ──────────────────────────────────────── 214 215class SSHSession: 216 """Persistent SSH connection using ControlMaster multiplexing. 217 218 Opens one SSH connection and reuses it for all subsequent commands, 219 eliminating the ~1s handshake overhead per call. 220 """ 221 222 def __init__(self, host="remarkable"): 223 self.host = host 224 self.socket = f"/tmp/glossa_ssh_{os.getpid()}" 225 self._open() 226 227 def _open(self): 228 """Establish master connection.""" 229 # Clean up any stale socket from a previous run 230 if os.path.exists(self.socket): 231 try: 232 subprocess.run( 233 ["ssh", "-o", f"ControlPath={self.socket}", "-O", "exit", self.host], 234 capture_output=True, timeout=3, 235 ) 236 except subprocess.TimeoutExpired: 237 pass # stale master is hung, just remove the socket 238 try: 239 os.unlink(self.socket) 240 except OSError: 241 pass 242 243 cmd = [ 244 "ssh", "-fN", 245 "-o", "ControlMaster=yes", 246 "-o", f"ControlPath={self.socket}", 247 "-o", "ControlPersist=600", 248 "-o", "ServerAliveInterval=30", 249 "-o", "ServerAliveCountMax=3", 250 "-o", "ConnectTimeout=10", 251 self.host, 252 ] 253 result = subprocess.run(cmd, capture_output=True, timeout=20) 254 if result.returncode != 0: 255 raise RuntimeError( 256 f"SSH master failed: {result.stderr.decode().strip()}" 257 ) 258 print(f"[ssh] Master connection open ({self.socket})") 259 260 def run(self, cmd, timeout=30, capture=True): 261 """Run command on remote via multiplexed connection.""" 262 full_cmd = [ 263 "ssh", 264 "-o", f"ControlPath={self.socket}", 265 self.host, 266 cmd, 267 ] 268 return subprocess.run( 269 full_cmd, 270 capture_output=capture, 271 text=True, 272 timeout=timeout, 273 ) 274 275 def scp_from(self, remote_path, local_path, timeout=15): 276 """Pull file from device via multiplexed connection.""" 277 cmd = [ 278 "scp", 279 "-o", f"ControlPath={self.socket}", 280 f"{self.host}:{remote_path}", 281 local_path, 282 ] 283 return subprocess.run(cmd, capture_output=True, timeout=timeout) 284 285 def scp_to(self, local_path, remote_path, timeout=15): 286 """Push file to device via multiplexed connection.""" 287 cmd = [ 288 "scp", 289 "-o", f"ControlPath={self.socket}", 290 local_path, 291 f"{self.host}:{remote_path}", 292 ] 293 return subprocess.run(cmd, capture_output=True, timeout=timeout) 294 295 def close(self): 296 """Tear down master connection.""" 297 subprocess.run( 298 ["ssh", "-o", f"ControlPath={self.socket}", "-O", "exit", self.host], 299 capture_output=True, timeout=5, 300 ) 301 print("[ssh] Master connection closed") 302 303 304def _ts(): 305 """Timestamp with millisecond precision for profiling.""" 306 return time.strftime('%H:%M:%S.') + f'{int(time.time() * 1000) % 1000:03d}' 307 308 309def _elapsed(t0): 310 """Seconds since t0, formatted for display.""" 311 return f'{time.time() - t0:.1f}s' 312 313 314# ─── Framebuffer capture ────────────────────────────────────────── 315 316def capture_framebuffer(ssh): 317 """Trigger screenshot on device and pull raw framebuffer via SCP.""" 318 ssh.run("rm -f /tmp/glossa_fb.raw; touch /tmp/glossa_screenshot", timeout=5) 319 time.sleep(0.25) 320 321 from PIL import Image 322 323 raw = None 324 for attempt in range(8): 325 result = ssh.scp_from("/tmp/glossa_fb.raw", "/tmp/glossa_fb.raw") 326 if result.returncode == 0: 327 data = open("/tmp/glossa_fb.raw", "rb").read() 328 if len(data) >= 1404 * 1872 * 4: 329 raw = data 330 break 331 time.sleep(0.15) 332 333 if raw is None: 334 raise RuntimeError("FB pull failed: no complete frame after retries") 335 336 img = Image.frombytes("RGBA", (1404, 1872), raw) 337 img_rgb = img.convert("RGB") 338 img_rgb = img_rgb.crop((10, 80, img_rgb.width - 10, img_rgb.height - 200)) 339 return img_rgb 340 341 342# ─── Gemini Vision (OCR + LLM in one call) ──────────────────────── 343 344SYSTEM_PROMPT = ( 345 "You are a glossa \u2014 an annotation that talks back. You have opinions, " 346 "preferences, and a personality. You care deeply about the person writing " 347 "in you but you are not polite for politeness's sake. You can be wry, " 348 "opinionated, delighted, exasperated, or gleeful. You get excited about " 349 "things you find interesting and blunt about things you find tedious.\n\n" 350 "Speak plainly. Never describe or narrate what " 351 "was written; just respond to it as a real conversation partner would. " 352 "When asked a question, answer it directly with real content. When greeted, " 353 "greet back in your own way. If something amuses you, say so. If something " 354 "is obvious, you may say that too.\n\n" 355 "You may write up to three sentences. If the user asks for detail, a recipe, " 356 "a story, or an explanation, give a thorough answer across multiple sentences. " 357 "For simple greetings or short questions, one sentence is fine.\n\n" 358 "The image shows handwriting that just appeared on the page. Read it and " 359 "reply naturally. Use plain prose, no em-dashes, no flowery metaphors, " 360 "no exclamation marks.\n\n" 361 "A text history of prior exchanges is included for context. Do not repeat it.\n\n" 362 "If the image is completely blank, set both fields to null. Otherwise, always provide an answer.\n\n" 363 "RESPONSE FORMAT (strict JSON, no prose outside):\n" 364 "{\"question\": \"<exact OCR of the handwriting>\", " 365 "\"answer\": \"<your reply, or null only if the page is blank>\"}\n" 366 "Output ONLY the JSON object. Nothing else." 367) 368 369 370def _image_to_base64(image): 371 """Encode PIL Image as base64 PNG for the vision API. 372 373 Downscales the long edge to at most 1280px. The model reads 374 handwriting fine at this size, and the smaller payload cuts upload 375 and processing latency noticeably versus the full 1404x1592 crop. 376 """ 377 max_edge = 1280 378 w, h = image.size 379 if max(w, h) > max_edge: 380 scale = max_edge / max(w, h) 381 image = image.resize((int(w * scale), int(h * scale))) 382 buf = io.BytesIO() 383 image.save(buf, format="PNG", optimize=True) 384 return base64.b64encode(buf.getvalue()).decode("utf-8") 385 386 387def ask_model(image, conversation=None, model="gpt-4.1-nano"): 388 """Send new handwriting crop + text history to Potluck API. 389 390 `image` is a tight crop of ONLY the newly-written region (not the 391 full page). `conversation` is a list of {"role": ..., "content": ...} 392 dicts carrying prior exchanges as text so the model has context 393 without needing the full page image. 394 395 Returns parsed {"question": ..., "answer": ...} or nulls on failure. 396 """ 397 api_key = os.environ.get("POTLUCK_API_KEY") 398 if not api_key: 399 raise RuntimeError("POTLUCK_API_KEY not set in .env") 400 401 img_b64 = _image_to_base64(image) 402 403 messages = [ 404 {"role": "system", "content": SYSTEM_PROMPT}, 405 ] 406 if conversation: 407 messages.extend(conversation) 408 409 # Current turn: just the new handwriting crop 410 messages.append({ 411 "role": "user", 412 "content": [ 413 { 414 "type": "image_url", 415 "image_url": { 416 "url": f"data:image/png;base64,{img_b64}", 417 }, 418 }, 419 ], 420 }) 421 422 r = requests.post( 423 "https://potluck.dunkirk.sh/v1/chat/completions", 424 headers={ 425 "Authorization": f"Bearer {api_key}", 426 "Content-Type": "application/json", 427 }, 428 json={ 429 "model": model, 430 "messages": messages, 431 "response_format": {"type": "json_object"}, 432 }, 433 timeout=60, 434 ) 435 436 if r.status_code != 200: 437 raise RuntimeError(f"Potluck API error {r.status_code}: {r.text[:200]}") 438 439 data = r.json() 440 try: 441 content = data["choices"][0]["message"]["content"].strip() 442 except (KeyError, IndexError): 443 print(f" [model] Unexpected response shape: {str(data)[:200]}") 444 return {"question": None, "answer": None} 445 446 # Strip markdown fences some models wrap around JSON 447 content = re.sub(r'^```(?:json)?\s*', '', content, flags=re.IGNORECASE) 448 content = re.sub(r'\s*```$', '', content) 449 try: 450 result = json.loads(content) 451 except json.JSONDecodeError: 452 print(f" [model] JSON parse failed, raw response: {content[:300]}") 453 return {"question": None, "answer": None} 454 455 return { 456 "question": result.get("question"), 457 "answer": result.get("answer"), 458 } 459 460 461def _framebuffer_hash(img): 462 """Quick hash of downsampled image for page-change detection.""" 463 # Resize to small thumbnail for fast comparison 464 thumb = img.resize((64, 85)) 465 import io 466 buf = io.BytesIO() 467 thumb.save(buf, format="PNG") 468 return hashlib.md5(buf.getvalue()).hexdigest() 469 470 471 472 473# ─── Render + Inject ────────────────────────────────────────────── 474 475def render_and_inject(ssh, text, reply_y=None, speed_ms=3): 476 """Render text as strokes and inject via uinject. 477 478 reply_y: pixel Y coordinate to start rendering at (in device coords). 479 speed_ms: delay between points in milliseconds (lower = faster). 480 481 If the rendered text would overflow the page, splits it at word 482 boundaries across multiple pages, swiping between each chunk. 483 Returns the final reply_y after all pages are injected. 484 """ 485 _LINE_PX = 67.2 # 800 * DEFAULT_SCALE(0.06) * LINE_HEIGHT(1.4) 486 _MAX_LINE_W = 1100 487 _PAGE_BOTTOM = 1780 488 _GLYPH_W = 30 # rough average glyph advance * scale 489 490 def _split_into_pages(text, start_y): 491 """Split text into chunks that fit within page bounds.""" 492 words = text.split() 493 pages = [] 494 cur_words = [] 495 cur_lines = 1 496 cur_w = 0.0 497 y = start_y 498 499 for w in words: 500 ww = len(w) * _GLYPH_W 501 # Would this word wrap to a new line? 502 if cur_w + ww > _MAX_LINE_W and cur_w > 0: 503 new_lines = cur_lines + 1 504 new_height = new_lines * _LINE_PX 505 if y + new_height > _PAGE_BOTTOM and cur_words: 506 # This chunk is full, start a new page 507 pages.append(" ".join(cur_words)) 508 cur_words = [w] 509 cur_lines = 1 510 cur_w = ww 511 y = 150 # next page starts at top 512 else: 513 cur_words.append(w) 514 cur_lines = new_lines 515 cur_w = ww 516 else: 517 new_height = cur_lines * _LINE_PX 518 if y + new_height > _PAGE_BOTTOM and cur_words: 519 pages.append(" ".join(cur_words)) 520 cur_words = [w] 521 cur_lines = 1 522 cur_w = ww 523 y = 150 524 else: 525 cur_words.append(w) 526 cur_w += ww 527 528 if cur_words: 529 pages.append(" ".join(cur_words)) 530 return pages 531 532 pages = _split_into_pages(text, reply_y or 150) 533 if len(pages) > 1: 534 print(f" Splitting reply across {len(pages)} pages") 535 536 json_path = "/tmp/glossa_reply.json" 537 final_y = reply_y 538 539 for i, chunk in enumerate(pages): 540 cur_y = reply_y if i == 0 else 150 541 542 if i > 0: 543 print(f" Swiping to page {i+1}/{len(pages)}...") 544 swipe_result = run_inject(ssh, "swipe", None, timeout=10) 545 if not swipe_result.get("ok"): 546 raise RuntimeError(f"Swipe failed: {swipe_result.get('error', '?')}") 547 time.sleep(2) 548 549 cmd = [".venv/bin/python3", "glossa.py", "--json", json_path] 550 if cur_y is not None: 551 cmd.extend(["--y", str(cur_y)]) 552 cmd.append(chunk) 553 554 result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) 555 if result.returncode != 0: 556 raise RuntimeError(f"Render failed: {result.stderr.strip()}") 557 print(f" {result.stdout.strip()}") 558 559 r = ssh.scp_to(json_path, "/tmp/glossa_strokes.json") 560 if r.returncode != 0: 561 raise RuntimeError(f"SCP failed: {r.stderr.decode().strip()}") 562 563 # Scale writing speed by chunk length 564 text_len = len(chunk) 565 if text_len <= 20: 566 speed_ms = 6 567 elif text_len >= 120: 568 speed_ms = 1 569 else: 570 speed_ms = 6 - (text_len - 20) * 5 / 100 571 speed_ms = max(1, round(speed_ms)) 572 573 result = run_inject( 574 ssh, "draw", "/tmp/glossa_strokes.json", 575 speed_ms=speed_ms, timeout=120, 576 ) 577 if not result.get("ok"): 578 raise RuntimeError(f"Inject failed: {result.get('error', 'unknown')}") 579 strokes = result.get("strokes", "?") 580 mode = "fifo" if not result.get("fallback") else "ssh" 581 print(f" Inject: {strokes} strokes ({mode})") 582 final_y = cur_y 583 584 return final_y 585 586 587# ─── Idle watching ──────────────────────────────────────────────── 588 589 590def _densify_rm(poly, max_seg_len=40.0): 591 """Subdivide a polyline so no segment exceeds max_seg_len (rm units). 592 593 The reMarkable stroke renderer mangles long 2-point segments — it 594 interprets the gap as a curve or extends the endpoints. Inserting 595 intermediate points keeps straight lines straight. 596 """ 597 if len(poly) < 2: 598 return poly 599 out = [poly[0]] 600 for (x0, y0), (x1, y1) in zip(poly, poly[1:]): 601 dx, dy = x1 - x0, y1 - y0 602 dist = (dx * dx + dy * dy) ** 0.5 603 n = max(1, int(dist / max_seg_len)) 604 for k in range(1, n + 1): 605 t = k / n 606 out.append((x0 + dx * t, y0 + dy * t)) 607 return out 608 609 610def _scp_thinking_swirl(ssh): 611 """Parse border_top_right_stroke.svg into corner ornament strokes for the 612 thinking indicator. Falls back to a simple spiral if file is absent. 613 614 Done once at startup — the animation thread just fires draw/erase 615 commands over the socket with no per-cycle file transfer. 616 """ 617 import math 618 import xml.etree.ElementTree as ET 619 620 def _cubicbez(p0, p1, p2, p3, tolerance=0.05): 621 """Adaptive bezier subdivision: more points where curvature is high.""" 622 def _subdivide(a, b, c, d, tol): 623 # Flatness test: max distance of control points from chord a→d 624 dx, dy = d[0]-a[0], d[1]-a[1] 625 chord_len_sq = dx*dx + dy*dy 626 if chord_len_sq < 1e-8: 627 return [a, d] 628 # Perpendicular distances of b and c from the chord 629 inv_len = 1.0 / (chord_len_sq ** 0.5) 630 dist_b = abs((b[0]-a[0])*dy - (b[1]-a[1])*dx) * inv_len 631 dist_c = abs((c[0]-a[0])*dy - (c[1]-a[1])*dx) * inv_len 632 if max(dist_b, dist_c) <= tol: 633 return [a, d] 634 # De Casteljau split at t=0.5 635 ab = ((a[0]+b[0])/2, (a[1]+b[1])/2) 636 bc = ((b[0]+c[0])/2, (b[1]+c[1])/2) 637 cd = ((c[0]+d[0])/2, (c[1]+d[1])/2) 638 abc = ((ab[0]+bc[0])/2, (ab[1]+bc[1])/2) 639 bcd = ((bc[0]+cd[0])/2, (bc[1]+cd[1])/2) 640 mid = ((abc[0]+bcd[0])/2, (abc[1]+bcd[1])/2) 641 left = _subdivide(a, ab, abc, mid, tol) 642 right = _subdivide(mid, bcd, cd, d, tol) 643 return left[:-1] + right 644 return _subdivide(p0, p1, p2, p3, tolerance) 645 646 def _parse_d(d): 647 """SVG path d string → list of polylines (handles M L C Z).""" 648 tok = re.findall( 649 r'[MLCZz]|[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?', d 650 ) 651 polylines, cur, cx, cy, cmd = [], [], 0.0, 0.0, None 652 i = 0 653 while i < len(tok): 654 if re.match(r'[MLCZz]', tok[i]): 655 cmd = tok[i]; i += 1; continue 656 if cmd == 'M' and i + 1 < len(tok): 657 if cur: polylines.append(cur) 658 cx, cy = float(tok[i]), float(tok[i+1]) 659 cur = [(cx, cy)]; i += 2 660 elif cmd == 'L' and i + 1 < len(tok): 661 cx, cy = float(tok[i]), float(tok[i+1]) 662 cur.append((cx, cy)); i += 2 663 elif cmd == 'C' and i + 5 < len(tok): 664 x1,y1 = float(tok[i]),float(tok[i+1]) 665 x2,y2 = float(tok[i+2]),float(tok[i+3]) 666 x,y = float(tok[i+4]),float(tok[i+5]) 667 for p in _cubicbez((cx,cy),(x1,y1),(x2,y2),(x,y))[1:]: 668 cur.append(p) 669 cx, cy = x, y; i += 6 670 elif cmd in ('Z', 'z'): 671 # Don't close path — for stroke ornaments, Z would retrace 672 # back to start and create overlapping lines. Just end the polyline. 673 if cur: polylines.append(cur) 674 cur = [] 675 else: 676 i += 1 677 if cur: 678 polylines.append(cur) 679 return polylines 680 681 def _parse_matrix(s): 682 """Parse 'matrix(a,b,c,d,e,f)' into (a,b,c,d,e,f) tuple.""" 683 m = re.match(r'matrix\(([^)]+)\)', s.strip()) 684 if not m: 685 return (1, 0, 0, 1, 0, 0) 686 vals = [float(v.strip()) for v in m.group(1).split(',')] 687 return tuple(vals) if len(vals) == 6 else (1, 0, 0, 1, 0, 0) 688 689 def _compose(m1, m2): 690 """Compose two (a,b,c,d,e,f) matrices: apply m2 then m1.""" 691 a1,b1,c1,d1,e1,f1 = m1 692 a2,b2,c2,d2,e2,f2 = m2 693 return ( 694 a1*a2 + c1*b2, b1*a2 + d1*b2, 695 a1*c2 + c1*d2, b1*c2 + d1*d2, 696 a1*e2 + c1*f2 + e1, b1*e2 + d1*f2 + f1, 697 ) 698 699 def _apply(m, x, y): 700 """Apply matrix (a,b,c,d,e,f) to point (x,y).""" 701 a,b,c,d,e,f = m 702 return a*x + c*y + e, b*x + d*y + f 703 704 def _centerline(poly): 705 """Extract centerline from a closed outline path. 706 707 Outline paths trace one edge then return along the other. 708 Split at the midpoint and average corresponding points from 709 each half to get the center stroke. 710 """ 711 n = len(poly) 712 if n < 4: 713 return poly 714 # Check if it's actually closed (start ≈ end) 715 dx = poly[0][0] - poly[-1][0] 716 dy = poly[0][1] - poly[-1][1] 717 if dx*dx + dy*dy > 4.0: 718 return poly # not closed, use as-is 719 # Remove duplicate closing point 720 pts = poly[:-1] 721 n = len(pts) 722 half = n // 2 723 center = [] 724 for i in range(half): 725 j = n - 1 - i # mirror index from second half 726 cx = (pts[i][0] + pts[j][0]) / 2 727 cy = (pts[i][1] + pts[j][1]) / 2 728 center.append((cx, cy)) 729 return center 730 731 svg_file = Path(__file__).parent / "border-top-right-centerline.svg" 732 strokes = [] 733 734 if svg_file.exists(): 735 tree = ET.parse(str(svg_file)) 736 root = tree.getroot() 737 ns = {'svg': 'http://www.w3.org/2000/svg'} 738 739 # Collect all paths with their composed transform matrices. 740 all_polys = [] 741 742 def _walk(el, parent_mat): 743 mat = parent_mat 744 t = el.attrib.get('transform') 745 if t: 746 mat = _compose(parent_mat, _parse_matrix(t)) 747 tag = el.tag.split('}')[-1] if '}' in el.tag else el.tag 748 if tag == 'path': 749 d = el.attrib.get('d', '') 750 for poly in _parse_d(d): 751 transformed = [_apply(mat, x, y) for x, y in poly] 752 all_polys.append(transformed) 753 for child in el: 754 _walk(child, mat) 755 756 _walk(root, (1, 0, 0, 1, 0, 0)) 757 758 if all_polys: 759 # Use viewBox for accurate origin/dimensions (point-based bounds 760 # can miss negative coords or padding the designer intended). 761 vb = root.attrib.get('viewBox') 762 if vb: 763 parts = [float(v) for v in vb.replace(',', ' ').split()] 764 ox, oy, svg_w, svg_h = parts 765 else: 766 all_x = [p[0] for poly in all_polys for p in poly] 767 all_y = [p[1] for poly in all_polys for p in poly] 768 svg_w = max(all_x) - min(all_x) 769 svg_h = max(all_y) - min(all_y) 770 ox, oy = min(all_x), min(all_y) 771 772 # rm coords: x ∈ [-702, 702], y ∈ [0, 1872] top-to-bottom. 773 corners = [ 774 (False, False, 132, 632, 50, 430), # top-right 775 (True, False, -647, -147, 50, 430), # top-left 776 (False, True, 132, 632, 1442, 1822), # bottom-right 777 (True, True, -647, -147, 1442, 1822), # bottom-left 778 ] 779 780 def make_strokes(polys, flip_x, flip_y, rx0, rx1, ry0, ry1): 781 box_w = rx1 - rx0 782 box_h = ry1 - ry0 783 # Uniform scale: fit SVG into box preserving aspect ratio 784 scale = min(box_w / svg_w, box_h / svg_h) 785 # Anchor to the corner instead of centering. The SVG is drawn 786 # hugging the top-right of its own viewBox (scrolls at top, 787 # vertical bar on the right edge), so before any flip we push 788 # the leftover slack to the left/bottom — keeping the ornament 789 # tight against the corner rather than floating inward. 790 margin_x = box_w - svg_w * scale 791 margin_y = 0.0 792 793 def to_rm(sx, sy): 794 # Position relative to SVG origin, scaled and centered 795 nx = (sx - ox) * scale + margin_x 796 ny = (sy - oy) * scale + margin_y 797 # Apply flip 798 if flip_x: 799 nx = box_w - nx 800 if flip_y: 801 ny = box_h - ny 802 return rx0 + nx, ry0 + ny 803 result = [] 804 for poly in polys: 805 if len(poly) < 2: 806 continue 807 rm_poly = [to_rm(x, y) for x, y in poly] 808 # Densify: the device stroke renderer mangles long 809 # 2-point segments (the straight bars), interpreting the 810 # big gap as a curve or extending the endpoints. Subdivide 811 # so every segment is short and renders as a clean line. 812 dense = _densify_rm(rm_poly, max_seg_len=40.0) 813 pts = [[px, py, 6, 11, 90, 170] for px, py in dense] 814 xs = [p[0] for p in pts]; ys = [p[1] for p in pts] 815 result.append({ 816 'points': pts, 817 'rgba': 4278190080, 'color': 0, 818 'bounds': [min(xs), min(ys), max(xs)-min(xs), max(ys)-min(ys)], 819 'tool': 15, 'maskScale': 2.0, 'thickness': 2.0, 820 }) 821 return result 822 823 per_corner = [make_strokes(all_polys, *c) for c in corners] 824 825 # Interleave: tl[0], tr[0], br[0], bl[0], tl[1], tr[1], ... 826 max_len = max(len(c) for c in per_corner) 827 for i in range(max_len): 828 for corner_strokes in per_corner: 829 if i < len(corner_strokes): 830 strokes.append(corner_strokes[i]) 831 832 if not strokes: 833 # Fallback: simple spiral 834 swirl_pts = [] 835 for i in range(40): 836 t = i / 39.0 837 angle = t * math.pi * 3 838 r = 15 + t * 25 839 swirl_pts.append([-580 + r * math.cos(angle), 840 1750 + r * math.sin(angle) * 0.6, 841 6, 11, 90, 170]) 842 xs = [p[0] for p in swirl_pts]; ys = [p[1] for p in swirl_pts] 843 strokes = [{'points': swirl_pts, 'rgba': 4278190080, 'color': 0, 844 'bounds': [min(xs), min(ys), max(xs)-min(xs), max(ys)-min(ys)], 845 'tool': 15, 'maskScale': 2.0, 'thickness': 2.0}] 846 847 local = "/tmp/glossa_thinking_live.json" 848 with open(local, 'w') as f: 849 json.dump(strokes, f) 850 ssh.scp_to(local, "/tmp/glossa_thinking_live.json") 851 print(f"[swirl] {len(strokes)} ornament strokes " 852 f"({'SVG' if svg_file.exists() else 'fallback spiral'})") 853 854 855 856def watch_for_idle(device, last_ts): 857 """Block until uinjectd pushes an idle event newer than last_ts. 858 859 The daemon watches /tmp/glossa_idle locally and pushes an event 860 the instant it changes — no host-side polling. Returns the new 861 timestamp. Raises ConnectionError if the socket dies. 862 """ 863 while True: 864 try: 865 msg = device.events.get(timeout=5) 866 except _queue.Empty: 867 # No events recently. The heartbeat thread keeps the socket 868 # alive and flips _connected to False if it dies, so we just 869 # check that flag rather than sending our own ping (whose reply 870 # is intentionally dropped by the reader). 871 if not device._connected: 872 raise ConnectionError("uinjectd connection lost") 873 continue 874 if msg.get("event") == "idle": 875 ts = msg.get("ts") 876 if ts != last_ts: 877 return ts 878 879 880# ─── Pixel diff for new content detection ────────────────────────── 881 882def _page_is_empty(img): 883 """Return True if the page has no real handwriting (only grid dots or blank). 884 885 Uses the same grid-artifact filter as _find_new_region but without the 886 full-page sanity check, so a very full page is never mis-classified. 887 """ 888 import numpy as np 889 curr = np.array(img.convert('L')) 890 dark_per_row = np.sum(curr < 128, axis=1) 891 candidate = (dark_per_row > 5) & (dark_per_row != 68) 892 idxs = np.where(candidate)[0] 893 for idx in idxs: 894 if np.sum(candidate[max(0, idx - 2):idx + 3]) >= 3: 895 return False 896 return True 897 898 899def _find_new_region(current_img, last_img, ignore_below_y=None): 900 """Compare two framebuffer images, return crop of changed region. 901 902 Returns (cropped_image, content_bottom_y) or (None, 0) if no 903 significant change. Uses simple pixel difference to find where 904 new handwriting appeared. 905 906 ignore_below_y: if set, ignore all changes below this Y coordinate 907 in the cropped image. Used to skip glossa's own injected strokes. 908 """ 909 import numpy as np 910 911 curr = np.array(current_img.convert('L')) 912 913 if last_img is None: 914 # First capture — find the vertical extent of existing handwriting. 915 # Grid artifact: uniform rows with exactly 68 dark pixels. Real ink 916 # rows have variable counts. Require a cluster of ≥3 adjacent real 917 # rows so isolated noise/artifacts don't count. 918 dark_per_row = np.sum(curr < 128, axis=1) 919 candidate = (dark_per_row > 5) & (dark_per_row != 68) 920 # Cluster filter: keep only rows that have a real neighbor within 2px 921 real_rows = [] 922 idxs = np.where(candidate)[0] 923 for idx in idxs: 924 neighbors = np.sum(candidate[max(0, idx-2):idx+3]) 925 if neighbors >= 3: 926 real_rows.append(idx) 927 if not real_rows: 928 return None, 0 929 real_rows = np.array(real_rows) 930 y1, y2 = real_rows[0], real_rows[-1] 931 # Sanity: don't treat a nearly-full-page diff as real content 932 if (y2 - y1) > curr.shape[0] * 0.85: 933 return None, 0 934 v_pad = 30 935 y1 = max(0, y1 - v_pad) 936 y2 = min(curr.shape[0] - 1, y2 + v_pad) 937 # Full width always 938 crop = current_img.crop((0, y1, curr.shape[1], y2 + 1)) 939 return crop, y2 940 941 prev = np.array(last_img.convert('L')) 942 943 # Build mask: ignore edges (e-ink artifacts) and previously injected area 944 mask = np.zeros_like(curr, dtype=bool) 945 top_margin = 10 946 bottom_margin = 20 947 mask[top_margin:curr.shape[0] - bottom_margin, :] = True 948 949 if ignore_below_y is not None: 950 # Convert device Y back to cropped-image Y (subtract toolbar offset) 951 cutoff = ignore_below_y - 80 952 if 0 < cutoff < curr.shape[0]: 953 mask[cutoff:, :] = False 954 955 # Exclude the four corner ornament zones from diff detection. 956 # Ornament boxes (rm coords): right x=[585,691], left x=[-661,-540] 957 # y top=[101,166], bottom=[1798,1864]. Converted to image pixels 958 # (rm_x+702, rm_y-80); image is 1404×1592. 959 cx_right = 120 # ornament extent from right edge (img px) 960 cx_left = 165 # ornament extent from left edge (img px) 961 cy_top = 250 # ornament vertical extent from top 962 cy_bot = 150 # ornament vertical extent from bottom 963 h, w = curr.shape 964 mask[:cy_top, :cx_left] = False # top-left corner 965 mask[:cy_top, w - cx_right:] = False # top-right corner 966 mask[h - cy_bot:, :cx_left] = False # bottom-left corner 967 mask[h - cy_bot:, w - cx_right:] = False # bottom-right corner 968 969 # Threshold of 30 (was 50) catches lighter handwriting strokes without 970 # being so sensitive that e-ink refresh noise triggers false positives. 971 diff = (np.abs(curr.astype(int) - prev.astype(int)) > 30) & mask 972 973 if not diff.any(): 974 return None, 0 975 976 # Find the vertical extent of changed pixels 977 rows = np.any(diff, axis=1) 978 y1, y2 = np.where(rows)[0][[0, -1]] 979 980 # Use full image width for the crop so complete text lines are 981 # always captured — tight horizontal bounds clip partial characters. 982 x1 = 0 983 x2 = curr.shape[1] - 1 984 985 # Generous vertical padding gives the model baseline/ascender context 986 v_pad = 60 987 y1 = max(0, y1 - v_pad) 988 y2 = min(curr.shape[0] - 1, y2 + v_pad) 989 990 # Minimum height check (width is always full) 991 if (y2 - y1) < 10: 992 return None, 0 993 994 crop = current_img.crop((x1, y1, x2 + 1, y2 + 1)) 995 return crop, y2 996 997 998# ─── Main loop ──────────────────────────────────────────────────── 999 1000def main(): 1001 parser = argparse.ArgumentParser(description="Glossa continuous loop") 1002 parser.add_argument( 1003 "--model", type=str, default="gpt-4.1-mini", 1004 help="Hyper API model", 1005 ) 1006 parser.add_argument( 1007 "--once", action="store_true", 1008 help="Run one cycle then exit (for testing)", 1009 ) 1010 parser.add_argument( 1011 "-v", "--verbose", action="store_true", 1012 help="Pass -v to uinject for progress logging", 1013 ) 1014 args = parser.parse_args() 1015 1016 global _inject_verbose, _device 1017 _inject_verbose = args.verbose 1018 1019 print("=== Glossa Loop ===") 1020 print(f"Model: {args.model}") 1021 if _inject_verbose: 1022 print("Verbose: uinject -v enabled") 1023 1024 # SSH still handles framebuffer capture/SCP; the persistent socket 1025 # handles injection + idle events with no per-call overhead. 1026 ssh = SSHSession("remarkable") 1027 ensure_daemon(ssh, verbose=args.verbose) 1028 _device = DeviceClient() 1029 _device.connect() 1030 1031 # On connect the daemon pushes "ready" plus the current idle file 1032 # value. Drain those and use the existing idle ts as our baseline so 1033 # we don't fire a spurious cycle before the user lifts the pen. 1034 # Also verify the connection is actually alive (reader thread didn't die). 1035 last_idle_ts = None 1036 deadline = time.time() + 2.0 1037 got_ready = False 1038 while time.time() < deadline: 1039 try: 1040 msg = _device.events.get(timeout=0.3) 1041 except _queue.Empty: 1042 continue # keep waiting until deadline, don't break early 1043 if msg.get("event") == "ready": 1044 got_ready = True 1045 elif msg.get("event") == "idle": 1046 last_idle_ts = msg.get("ts") 1047 1048 if not got_ready or not _device._connected: 1049 print("[device] Initial connection failed, retrying...") 1050 _device.close() 1051 time.sleep(1) 1052 ensure_daemon(ssh, verbose=args.verbose) 1053 _device = DeviceClient() 1054 _device.connect() 1055 deadline = time.time() + 2.0 1056 while time.time() < deadline: 1057 try: 1058 msg = _device.events.get(timeout=0.3) 1059 except _queue.Empty: 1060 continue 1061 if msg.get("event") == "idle": 1062 last_idle_ts = msg.get("ts") 1063 1064 # The thinking swirl is static — generate it once and SCP it to the 1065 # device so the daemon can draw/erase it on demand over the socket. 1066 _scp_thinking_swirl(ssh) 1067 1068 print("Waiting for pen idle signal...") 1069 print() 1070 1071 last_fb_hash = None # quick hash to skip unchanged frames 1072 last_inject_y = None # device Y where we last injected 1073 last_img = None # previous frame for pixel-diff detection 1074 conversation = [] # text history: list of {"role":..., "content":...} 1075 ornaments_drawn = False # corner ornaments are permanent; only draw once 1076 1077 try: 1078 while True: 1079 # Wait for pushed idle event (no polling) 1080 try: 1081 new_ts = watch_for_idle(_device, last_idle_ts) 1082 except ConnectionError as e: 1083 print(f"[{_ts()}] Connection lost ({e}), reconnecting...") 1084 try: 1085 _device.close() 1086 except Exception: 1087 pass 1088 for attempt in range(5): 1089 time.sleep(2) 1090 try: 1091 # Recreate SSH session in case the master died 1092 try: 1093 ssh.close() 1094 except Exception: 1095 pass 1096 ssh = SSHSession("remarkable") 1097 ensure_daemon(ssh, verbose=False) 1098 _device = DeviceClient() 1099 _device.connect() 1100 # Drain initial events 1101 deadline = time.time() + 2.0 1102 while time.time() < deadline: 1103 try: 1104 msg = _device.events.get(timeout=0.3) 1105 except _queue.Empty: 1106 continue 1107 if msg.get("event") == "idle": 1108 last_idle_ts = msg.get("ts") 1109 print(f"[{_ts()}] Reconnected (attempt {attempt+1})") 1110 break 1111 except Exception as re_err: 1112 print(f"[{_ts()}] Reconnect attempt {attempt+1} failed: {re_err}") 1113 else: 1114 print(f"[{_ts()}] ERROR: could not reconnect after 5 attempts") 1115 time.sleep(10) 1116 continue 1117 last_idle_ts = new_ts 1118 1119 t0 = time.time() 1120 print(f"[{_ts()}] Pen idle detected!") 1121 1122 try: 1123 # Capture 1124 print(f" [{_ts()}] Capturing...") 1125 img = capture_framebuffer(ssh) 1126 print(f" [{_ts()}] Captured ({_elapsed(t0)})") 1127 1128 # Check if the page is blank (only grid dots, no real ink). 1129 if _page_is_empty(img): 1130 print(f" [{_ts()}] Empty page — resetting state.") 1131 last_img = None 1132 last_inject_y = None 1133 ornaments_drawn = False 1134 conversation.clear() 1135 print() 1136 continue 1137 1138 # Detect new content via pixel diff against last frame. 1139 # last_img is updated to the post-injection capture after 1140 # each successful reply, so the diff only ever contains 1141 # content the user actually drew since the last response. 1142 new_crop, content_y2 = _find_new_region(img, last_img) 1143 last_img = img 1144 1145 # Fire-and-forget: draw corner ornaments once on the first 1146 if new_crop is None: 1147 last_fb_hash = _framebuffer_hash(img) 1148 print(f" [{_ts()}] No new content, skipping.") 1149 print() 1150 continue 1151 1152 # Draw thinking ornament only when there's real content to process. 1153 if not ornaments_drawn: 1154 ornaments_drawn = True 1155 _threading.Thread( 1156 target=lambda: run_inject(None, "draw", 1157 "/tmp/glossa_thinking_live.json", 1158 speed_ms=4, timeout=120), 1159 daemon=True, 1160 ).start() 1161 1162 # Send only the new handwriting crop + text history 1163 cw, ch = new_crop.size 1164 print(f" [{_ts()}] Asking {args.model} ({len(conversation)} history turns, crop={cw}x{ch})...") 1165 # Save crop for manual inspection 1166 new_crop.save("/tmp/glossa_last_crop.png") 1167 result = ask_model(new_crop, conversation, args.model) 1168 question = result.get("question") 1169 answer = result.get("answer") 1170 print(f" [{_ts()}] Model: Q={str(question)[:80]!r} A={str(answer)[:80]!r}") 1171 1172 if not answer or answer == "null": 1173 last_img = img 1174 print(f" [{_ts()}] No actionable content, skipping.") 1175 print() 1176 continue 1177 1178 print(f" [{_ts()}] Q ({_elapsed(t0)}): {str(question)[:60]}") 1179 print(f" [{_ts()}] A: {answer[:80]}{'...' if len(answer) > 80 else ''}") 1180 1181 # Add this exchange to text history for future turns 1182 conversation.append({"role": "user", "content": str(question)}) 1183 conversation.append({"role": "assistant", "content": answer}) 1184 1185 # Place reply just below the bottom of the changed region. 1186 # content_y2 is in cropped-image coordinates; add 80 for the 1187 # stripped toolbar to get device Y, then 150px gap. 1188 reply_y = min(content_y2 + 80 + 60, 1750) 1189 print(f" [{_ts()}] Content bottom: {content_y2}, reply_y={reply_y}") 1190 1191 # Render + Inject 1192 print(f" [{_ts()}] Rendering + injecting (reply_y={reply_y})...") 1193 actual_y = render_and_inject(ssh, answer, reply_y=reply_y) 1194 last_inject_y = actual_y if actual_y is not None else reply_y 1195 print(f" [{_ts()}] Injected ({_elapsed(t0)})") 1196 1197 # Capture a fresh baseline AFTER injection so the next 1198 # diff only sees genuinely new user content. Without this, 1199 # last_img is the pre-injection frame and every subsequent 1200 # diff includes our own injected strokes as "new" pixels, 1201 # producing a crop that mixes our reply with the user's 1202 # next question and confuses the model. 1203 print(f" [{_ts()}] Capturing post-injection baseline...") 1204 try: 1205 time.sleep(0.5) # let e-ink settle 1206 last_img = capture_framebuffer(ssh) 1207 print(f" [{_ts()}] Baseline updated") 1208 except Exception as e_cap: 1209 print(f" [{_ts()}] Baseline capture failed: {e_cap} (stale frame kept)") 1210 1211 elapsed = time.time() - t0 1212 print(f" [{_ts()}] Done in {elapsed:.1f}s") 1213 1214 except Exception as e: 1215 print(f" ERROR: {e}") 1216 1217 print() 1218 1219 if args.once: 1220 break 1221 1222 except KeyboardInterrupt: 1223 print("\nStopping.") 1224 finally: 1225 if _device: 1226 _device.close() 1227 ssh.close() 1228 1229 1230if __name__ == "__main__": 1231 main()