edith-pc 0.3.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.
Files changed (79) hide show
  1. edith/__init__.py +13 -0
  2. edith/audio_io.py +130 -0
  3. edith/backup.py +260 -0
  4. edith/body/__init__.py +17 -0
  5. edith/body/__main__.py +116 -0
  6. edith/body/cli.py +224 -0
  7. edith/body/client.py +371 -0
  8. edith/body/desktop.py +478 -0
  9. edith/body/identity.py +10 -0
  10. edith/body/link.py +206 -0
  11. edith/body/pc_backup.py +347 -0
  12. edith/config.py +205 -0
  13. edith/gui.py +610 -0
  14. edith/gui_classic.py +352 -0
  15. edith/hud/__init__.py +11 -0
  16. edith/hud/easing.py +72 -0
  17. edith/hud/fonts.py +69 -0
  18. edith/hud/orb_renderer.py +66 -0
  19. edith/hud/textures.py +39 -0
  20. edith/legacy_migration.py +185 -0
  21. edith/logging_setup.py +53 -0
  22. edith/main.py +91 -0
  23. edith/pricing.py +143 -0
  24. edith/prompts.py +117 -0
  25. edith/realtime_client.py +685 -0
  26. edith/relay/__init__.py +12 -0
  27. edith/relay/__main__.py +4 -0
  28. edith/relay/bridge.py +333 -0
  29. edith/relay/crypto.py +186 -0
  30. edith/relay/server.py +147 -0
  31. edith/service/__init__.py +18 -0
  32. edith/service/__main__.py +130 -0
  33. edith/service/app.py +1180 -0
  34. edith/service/approvals.py +119 -0
  35. edith/service/brain.py +371 -0
  36. edith/service/devicebook.py +212 -0
  37. edith/service/devices.py +200 -0
  38. edith/service/phone_agent.py +266 -0
  39. edith/service/protocol.py +99 -0
  40. edith/service/tokens.py +32 -0
  41. edith/service/totp.py +61 -0
  42. edith/service/webauth.py +238 -0
  43. edith/session_manager.py +99 -0
  44. edith/tools/__init__.py +1 -0
  45. edith/tools/briefing.py +115 -0
  46. edith/tools/clock.py +13 -0
  47. edith/tools/computer_agent.py +419 -0
  48. edith/tools/delegate.py +99 -0
  49. edith/tools/documents.py +76 -0
  50. edith/tools/external_cost.py +36 -0
  51. edith/tools/file_ops.py +189 -0
  52. edith/tools/google_workspace.py +205 -0
  53. edith/tools/health_watch.py +70 -0
  54. edith/tools/home_assistant.py +142 -0
  55. edith/tools/llm.py +28 -0
  56. edith/tools/memory.py +205 -0
  57. edith/tools/news_finance.py +137 -0
  58. edith/tools/notes.py +50 -0
  59. edith/tools/pc_control.py +474 -0
  60. edith/tools/registry.py +942 -0
  61. edith/tools/reminders.py +129 -0
  62. edith/tools/spotify_music.py +141 -0
  63. edith/tools/system_control.py +197 -0
  64. edith/tools/system_toggles.py +261 -0
  65. edith/tools/task_manager.py +137 -0
  66. edith/tools/telegram_notify.py +36 -0
  67. edith/tools/vision.py +109 -0
  68. edith/tools/weather.py +57 -0
  69. edith/tools/web_search.py +65 -0
  70. edith/tools/whatsapp.py +97 -0
  71. edith/tools/window_control.py +146 -0
  72. edith/vault.py +395 -0
  73. edith/wake_word.py +159 -0
  74. edith_pc-0.3.0.dist-info/METADATA +75 -0
  75. edith_pc-0.3.0.dist-info/RECORD +79 -0
  76. edith_pc-0.3.0.dist-info/WHEEL +5 -0
  77. edith_pc-0.3.0.dist-info/entry_points.txt +5 -0
  78. edith_pc-0.3.0.dist-info/licenses/LICENSE +21 -0
  79. edith_pc-0.3.0.dist-info/top_level.txt +1 -0
edith/body/cli.py ADDED
@@ -0,0 +1,224 @@
1
+ """The `edith` command: this PC as a body of your EDITH server, installed with pip.
2
+
3
+ pip install edith-pc
4
+ edith link link this PC with a code from Google Authenticator; it then runs in
5
+ the background and starts with Windows
6
+ edith status what this PC is linked to, and whether EDITH is running
7
+ edith start start the background body edith stop stop it
8
+ edith app the EDITH window (the same one the installer's app shows)
9
+ edith run the body in this terminal (Ctrl+C stops it)
10
+ edith update install the newest EDITH (from PyPI, or wherever pip got it) and restart
11
+ edith unlink unlink this PC and stop starting with Windows
12
+
13
+ No token, key or .env to copy: the code from the authenticator is the only thing typed.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import subprocess
20
+ import sys
21
+ import time
22
+
23
+ from .. import __version__
24
+ from . import desktop
25
+ from . import link as linking
26
+
27
+
28
+ def _short(server: str) -> str:
29
+ return server.replace("https://", "")
30
+
31
+
32
+ def _wait_linked(seconds: float = 20.0) -> dict:
33
+ """The background body's status once it says linked (or gives up), for a one-line report."""
34
+ end = time.time() + seconds
35
+ st: dict = {}
36
+ while time.time() < end:
37
+ st = desktop.read_status()
38
+ if desktop.body_running() and st.get("state") in ("linked", "refused", "unlinked"):
39
+ break
40
+ time.sleep(0.5)
41
+ return st
42
+
43
+
44
+ def _report_start() -> int:
45
+ st = _wait_linked()
46
+ if st.get("state") == "linked":
47
+ print(f"EDITH is running in the background: {st.get('text')}")
48
+ return 0
49
+ print(f"EDITH started, but it says: {st.get('text') or 'nothing yet'}. Check with: edith status")
50
+ return 1
51
+
52
+
53
+ def cmd_link(args) -> int:
54
+ default = linking.default_server()
55
+ server = args.server
56
+ while not server: # the address first: no point asking for a code that expires meanwhile
57
+ typed = input(f"EDITH server [{_short(default)}]: " if default else "EDITH server (e.g. edith.example.com): ").strip()
58
+ try:
59
+ server = linking.normalize_server(typed or default)
60
+ except linking.LinkError as exc:
61
+ print(exc)
62
+ code = args.code or input("Code from Google Authenticator (6 digits): ").strip()
63
+ desktop.stop_background() # one still running must not overwrite the new key when it stops
64
+ try:
65
+ made = linking.link(server, code)
66
+ except linking.LinkError as exc:
67
+ print(f"Not linked: {exc}")
68
+ return 1
69
+ print(f"Linked to {_short(made['server'])} as {made.get('name') or made['device_id']}.")
70
+ if not args.no_autostart:
71
+ desktop.set_autostart(True)
72
+ print("EDITH starts with Windows from now on." if desktop.autostart() else
73
+ "EDITH couldn't be set to start with Windows; start it with: edith start")
74
+ desktop.start_background()
75
+ return _report_start()
76
+
77
+
78
+ def cmd_status(args) -> int:
79
+ cfg = linking.load()
80
+ if not linking.linked(cfg):
81
+ print("This PC is not linked. Link it with: edith link")
82
+ return 1
83
+ running, st = desktop.body_running(), desktop.read_status()
84
+ print(f"Linked to {_short(str(cfg.get('server')))} as {cfg.get('name') or cfg.get('device_id')}.")
85
+ print(f"Background body: {'running, ' + str(st.get('state')) if running else 'not running (start it: edith start)'}")
86
+ if running and st.get("text"):
87
+ print(f" {st['text']}")
88
+ command = desktop.autostart()
89
+ started_by = "no" if not command else "yes" if desktop.autostart_is_python(command) else "yes, by the EDITH for Windows app"
90
+ print(f"Starts with Windows: {started_by}")
91
+ return 0
92
+
93
+
94
+ def cmd_start(args) -> int:
95
+ if not linking.linked():
96
+ print("This PC is not linked. Link it with: edith link")
97
+ return 1
98
+ if desktop.body_running():
99
+ print("EDITH is already running in the background.")
100
+ return 0
101
+ desktop.start_background()
102
+ return _report_start()
103
+
104
+
105
+ def cmd_stop(args) -> int:
106
+ if not desktop.body_running():
107
+ print("EDITH is not running.")
108
+ return 0
109
+ ok = desktop.stop_background()
110
+ print("Stopped." if ok else "It didn't stop; end the pythonw process in Task Manager.")
111
+ return 0 if ok else 1
112
+
113
+
114
+ def cmd_unlink(args) -> int:
115
+ desktop.stop_background()
116
+ had = linking.linked()
117
+ dropped = linking.unlink()
118
+ desktop.set_autostart(False)
119
+ if not had:
120
+ print("This PC wasn't linked.")
121
+ elif dropped:
122
+ print("Unlinked.")
123
+ else:
124
+ print("Unlinked here, but the server couldn't be told: remove this PC in EDITH's HUD (Bodies).")
125
+ return 0
126
+
127
+
128
+ DIST = "edith-pc" # the name on PyPI ("edith" there is someone else's project)
129
+
130
+
131
+ def _source() -> str | None:
132
+ """What to give pip to get the newest of this EDITH: the package's name when it came from
133
+ PyPI, else where pip got it (PEP 610's direct_url.json); None for a checkout."""
134
+ from importlib import metadata
135
+
136
+ try:
137
+ raw = metadata.distribution(DIST).read_text("direct_url.json")
138
+ except metadata.PackageNotFoundError:
139
+ return None
140
+ if not raw:
141
+ return DIST # installed from PyPI
142
+ try:
143
+ info = json.loads(raw)
144
+ except ValueError:
145
+ return None
146
+ url = str(info.get("url") or "")
147
+ if "vcs_info" in info and info["vcs_info"].get("vcs") == "git":
148
+ return f"{DIST} @ git+{url}"
149
+ if url.startswith(("http://", "https://")):
150
+ return f"{DIST} @ {url}"
151
+ return None # a local folder or an editable checkout
152
+
153
+
154
+ def cmd_update(args) -> int:
155
+ source = _source()
156
+ if source is None:
157
+ print("This EDITH wasn't installed from a link pip can fetch again (a checkout, or a local "
158
+ "folder). Update it there: git pull.")
159
+ return 1
160
+ was_running = desktop.body_running()
161
+ if was_running:
162
+ desktop.stop_background()
163
+ pip = [sys.executable, "-m", "pip", "install", "--disable-pip-version-check"]
164
+ if source == DIST:
165
+ print("Updating from PyPI ...")
166
+ steps = (pip + ["--upgrade", DIST],)
167
+ else:
168
+ print(f"Updating from {source.split('@', 1)[1].strip()} ...")
169
+ # the newest code even when its version number didn't change, then anything new it needs
170
+ steps = (pip + ["--upgrade", "--force-reinstall", "--no-deps", source], pip + [source])
171
+ for step in steps:
172
+ if subprocess.call(step) != 0:
173
+ print("The update failed; the installed EDITH is unchanged or partly updated. Try again.")
174
+ if was_running:
175
+ desktop.start_background()
176
+ return 1
177
+ print("Updated.")
178
+ if was_running:
179
+ desktop.start_background()
180
+ return _report_start()
181
+ return 0
182
+
183
+
184
+ def cmd_app(args) -> int:
185
+ return desktop.main([])
186
+
187
+
188
+ def cmd_run(args) -> int:
189
+ from .__main__ import main as body
190
+
191
+ return body([])
192
+
193
+
194
+ def main(argv: list[str] | None = None) -> int:
195
+ parser = argparse.ArgumentParser(prog="edith", description="EDITH on this PC: a body of your EDITH server.",
196
+ epilog="Start with: edith link")
197
+ parser.add_argument("--version", action="version", version=f"EDITH {__version__}")
198
+ sub = parser.add_subparsers(dest="command")
199
+ p = sub.add_parser("link", help="link this PC with a code from Google Authenticator")
200
+ p.add_argument("--server", help="the EDITH server's address (default: the one EDITH ships with)")
201
+ p.add_argument("--code", help="the 6-digit code (default: asked for)")
202
+ p.add_argument("--no-autostart", action="store_true", help="don't start EDITH with Windows")
203
+ p.set_defaults(func=cmd_link)
204
+ for name, func, text in (("status", cmd_status, "what this PC is linked to, and whether EDITH runs"),
205
+ ("start", cmd_start, "start the background body"),
206
+ ("stop", cmd_stop, "stop the background body"),
207
+ ("unlink", cmd_unlink, "unlink this PC and stop starting with Windows"),
208
+ ("update", cmd_update, "install the newest EDITH and restart it"),
209
+ ("app", cmd_app, "open the EDITH window"),
210
+ ("run", cmd_run, "run the body in this terminal")):
211
+ sub.add_parser(name, help=text).set_defaults(func=func)
212
+ args = parser.parse_args(sys.argv[1:] if argv is None else argv)
213
+ if not getattr(args, "func", None):
214
+ parser.print_help()
215
+ return 0 if linking.linked() else 1
216
+ try:
217
+ return int(args.func(args) or 0)
218
+ except (KeyboardInterrupt, EOFError):
219
+ print()
220
+ return 130
221
+
222
+
223
+ if __name__ == "__main__":
224
+ sys.exit(main())
edith/body/client.py ADDED
@@ -0,0 +1,371 @@
1
+ """The body client: one WebSocket to the server's /ws, tool calls in, results out."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import json
6
+ import logging
7
+ import os
8
+ import re
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Callable
12
+
13
+ from websockets.asyncio.client import connect
14
+ from websockets.exceptions import ConnectionClosed
15
+
16
+ from ..tools.registry import SERVER_TOOLS, run_tool, tool_schemas
17
+ from .identity import device_info
18
+
19
+ _log = logging.getLogger("edith.body")
20
+ MAX_MESSAGE = 8 * 1024 * 1024
21
+ MAX_PARALLEL_TOOLS = 4
22
+ REPLACED = 4409 # the server closed us because a newer connection of this PC arrived
23
+ UNLINKED = 4403 # the server closed us because this PC was removed (its own key stopped working)
24
+
25
+
26
+ def point_model_calls_at_server(server_url: str, token: str) -> None:
27
+ """Every tool builds its OpenAI client from the environment: send those calls to the
28
+ server's /v1 with the pairing token (or this PC's own key), so the real key never has
29
+ to be on this PC."""
30
+ os.environ["OPENAI_BASE_URL"] = server_url.rstrip("/") + "/v1"
31
+ os.environ["OPENAI_API_KEY"] = token
32
+
33
+
34
+ def pairing_token(settings) -> str:
35
+ """EDITH_SERVICE_TOKEN, else ~/.edith_service_token: the token the server also has."""
36
+ tok = (getattr(settings, "service_token", None) or "").strip()
37
+ if tok:
38
+ return tok
39
+ try:
40
+ return (Path.home() / ".edith_service_token").read_text(encoding="utf-8").strip()
41
+ except OSError:
42
+ return ""
43
+
44
+
45
+ class VoiceTokenError(RuntimeError):
46
+ """The server would not mint a voice session (402: its daily budget is used up)."""
47
+
48
+ def __init__(self, status: int, detail: str) -> None:
49
+ super().__init__(detail)
50
+ self.status = status
51
+ self.detail = detail
52
+
53
+
54
+ def _auth(token: str) -> dict:
55
+ return {"Authorization": f"Bearer {token}", "X-Requested-With": "edith"}
56
+
57
+
58
+ async def mint_voice_token(server_url: str, token: str, voice: str | None = None,
59
+ transport=None) -> dict:
60
+ """POST /token on the server: a short-lived Realtime client secret for this PC's voice
61
+ HUD, so no OpenAI key is needed here. Raises VoiceTokenError with the server's reason."""
62
+ import httpx
63
+
64
+ url = server_url.rstrip("/") + "/token"
65
+ try:
66
+ async with httpx.AsyncClient(timeout=20.0, trust_env=False, transport=transport) as http:
67
+ r = await http.post(url, json={"voice": voice, "body": "pc"}, headers=_auth(token))
68
+ except httpx.HTTPError as exc: # unreachable, timed out: a reason to retry, not to crash
69
+ raise VoiceTokenError(0, f"the server is unreachable ({type(exc).__name__})") from exc
70
+ if r.status_code != 200:
71
+ try:
72
+ detail = (r.json() or {}).get("detail") or r.text
73
+ except ValueError:
74
+ detail = r.text
75
+ raise VoiceTokenError(r.status_code, str(detail)[:200] or f"HTTP {r.status_code}")
76
+ try:
77
+ data = r.json()
78
+ except ValueError as exc:
79
+ raise VoiceTokenError(502, "the server's answer was not JSON") from exc
80
+ if not isinstance(data, dict) or not data.get("value"):
81
+ raise VoiceTokenError(502, "the server returned no secret")
82
+ return data
83
+
84
+
85
+ async def report_voice_usage(server_url: str, token: str, model: str, usage: dict,
86
+ transport=None) -> dict | None:
87
+ """POST /usage: the server books this session's usage on its daily budget and answers
88
+ with its spend {session, day, cap}; None when it could not be told."""
89
+ import httpx
90
+
91
+ url = server_url.rstrip("/") + "/usage"
92
+ try:
93
+ async with httpx.AsyncClient(timeout=15.0, trust_env=False, transport=transport) as http:
94
+ r = await http.post(url, json={"model": model, "usage": usage}, headers=_auth(token))
95
+ except httpx.HTTPError:
96
+ return None
97
+ if r.status_code != 200:
98
+ return None
99
+ try:
100
+ spend = (r.json() or {}).get("spend")
101
+ except ValueError:
102
+ return None
103
+ return spend if isinstance(spend, dict) else None
104
+
105
+
106
+ _BACKUP_NAME = re.compile(r"^edith-backup-\d{4}-\d{2}-\d{2}T\d{6}Z\.tar\.gz$")
107
+
108
+
109
+ async def fetch_server_backup(server_url: str, token: str, dest: Path, keep: int = 14,
110
+ transport=None) -> Path | None:
111
+ """The server brain's newest backup, into ``dest``: an off-server copy on this PC. None
112
+ when the server has none yet or can't be reached. The newest ``keep`` copies are kept."""
113
+ import httpx
114
+
115
+ dest.mkdir(parents=True, exist_ok=True)
116
+ url = server_url.rstrip("/") + "/backup/latest"
117
+ target = None
118
+ try:
119
+ async with httpx.AsyncClient(timeout=120.0, trust_env=False, transport=transport) as http:
120
+ async with http.stream("GET", url, headers=_auth(token)) as r:
121
+ if r.status_code != 200:
122
+ return None
123
+ m = re.search(r'filename="?([^";]+)"?', r.headers.get("content-disposition", ""))
124
+ name = Path(m.group(1)).name if m else ""
125
+ if not _BACKUP_NAME.match(name): # never a path or a name the server made up
126
+ name = time.strftime("edith-backup-%Y-%m-%dT%H%M%SZ.tar.gz", time.gmtime())
127
+ target = dest / name
128
+ if not target.exists():
129
+ part = target.with_name(name + ".part")
130
+ with open(part, "wb") as f:
131
+ async for chunk in r.aiter_bytes():
132
+ f.write(chunk)
133
+ part.replace(target)
134
+ except (httpx.HTTPError, OSError) as exc:
135
+ _log.warning("backup copy failed: %s", exc)
136
+ return None
137
+ for old in sorted(dest.glob("edith-backup-*.tar.gz"), key=lambda p: p.name, reverse=True)[keep:]:
138
+ try:
139
+ old.unlink()
140
+ except OSError:
141
+ pass
142
+ return target
143
+
144
+
145
+ def ws_url(server_url: str) -> str:
146
+ url = server_url.strip().rstrip("/")
147
+ if url.startswith("https://"):
148
+ url = "wss://" + url[len("https://"):]
149
+ elif url.startswith("http://"):
150
+ url = "ws://" + url[len("http://"):]
151
+ elif not url.startswith(("ws://", "wss://")):
152
+ url = "wss://" + url
153
+ return url + "/ws"
154
+
155
+
156
+ def pc_tool_names() -> list[str]:
157
+ """What this PC offers: every PC tool the server does not run itself."""
158
+ return [t["name"] for t in tool_schemas("pc") if t["name"] not in SERVER_TOOLS]
159
+
160
+
161
+ class Body:
162
+ def __init__(self, server_url: str, token: str, *, say: Callable[[str], None] = print,
163
+ run=None, backup_dir: Path | None = None, pc_backup: bool = False,
164
+ status_path: Path | None = None, on_unlinked: Callable[[], None] | None = None) -> None:
165
+ self.server_url = server_url
166
+ self.url = ws_url(server_url)
167
+ self.device_id = device_info()["id"] # until the server's welcome says otherwise
168
+ # which standard folders the HUD chose to back up (None: not told yet, so nothing)
169
+ self.backup_choice: list[str] | None = None
170
+ self.backup_settle = 300.0
171
+ self._backup_wake = asyncio.Event()
172
+ # the app's window shows what this body is doing: {state, text, at, pid, server, device}
173
+ self.status_path = status_path
174
+ # this PC was removed in the HUD (its own key stopped working): told once, then we stop
175
+ self.on_unlinked = on_unlinked
176
+ self._unlinked = False
177
+ # where to keep the daily copy of the server brain's backup (None: don't fetch)
178
+ self.backup_dir = backup_dir
179
+ self._backup_task: asyncio.Task | None = None
180
+ # back up this PC's folders into the server's vault, nightly (edith/body/pc_backup.py)
181
+ self.pc_backup = pc_backup
182
+ self._pc_backup_task: asyncio.Task | None = None
183
+ self.token = token
184
+ self._say = say
185
+ self._run = run or (lambda name, args: run_tool(name, args, role="pc"))
186
+ self._said = ""
187
+ self._limit = asyncio.Semaphore(MAX_PARALLEL_TOOLS)
188
+ # After being replaced (a second python -m edith.body on this PC?) this copy yields:
189
+ # every replaced_wait it asks for the link WITHOUT taking it, and gets it back once
190
+ # the other copy has stopped. A fresh start (or a link that came back) takes over,
191
+ # which is what replaces a dead connection the server has not noticed yet.
192
+ self.replaced_wait = 60.0
193
+ self._yield = False
194
+
195
+ def _status(self, text: str, state: str | None = None) -> None:
196
+ if text != self._said:
197
+ self._said = text
198
+ self._say(f" body: {text}")
199
+ _log.info("body: %s", text)
200
+ if state is not None:
201
+ self._state = state
202
+ if self.status_path is not None:
203
+ try:
204
+ tmp = self.status_path.with_name(self.status_path.name + ".tmp")
205
+ tmp.write_text(json.dumps({"state": getattr(self, "_state", "starting"), "text": text,
206
+ "at": int(time.time()), "pid": os.getpid(),
207
+ "server": self.server_url, "device": self.device_id}), encoding="utf-8")
208
+ os.replace(tmp, self.status_path)
209
+ except OSError:
210
+ pass
211
+
212
+ def _apply_config(self, config: dict | None) -> None:
213
+ """The server's word on this PC's backup: which standard folders go into the vault."""
214
+ folders = ((config or {}).get("backup") or {}).get("folders") if isinstance(config, dict) else None
215
+ if not isinstance(folders, list):
216
+ return
217
+ choice = [f for f in folders if isinstance(f, str)]
218
+ if choice != self.backup_choice:
219
+ self.backup_choice = choice
220
+ _log.info("backup: %s", ", ".join(choice) + " every night" if choice else "off for the standard folders")
221
+ self._backup_wake.set()
222
+
223
+ def _gone(self) -> None:
224
+ self._unlinked = True
225
+ self._status("this PC was removed from EDITH (in the HUD, or unlinked here); "
226
+ "open EDITH on this PC and link it again with a code", "unlinked")
227
+ if self.on_unlinked is not None:
228
+ try:
229
+ self.on_unlinked()
230
+ except Exception as exc:
231
+ _log.warning("forgetting the link failed: %s", exc)
232
+
233
+ async def run_forever(self) -> None:
234
+ """Link and serve until stopped; returns only when this PC was removed (unlinked)."""
235
+ backoff = 1.0
236
+ self._status(f"connecting to {self.server_url}", "connecting")
237
+ while True:
238
+ try:
239
+ async with connect(self.url, max_size=MAX_MESSAGE, ping_interval=20, ping_timeout=20,
240
+ open_timeout=15, proxy=None) as ws:
241
+ await ws.send(json.dumps({"type": "hello", "token": self.token, "kind": "pc",
242
+ "device": device_info(), "tools": pc_tool_names(),
243
+ "takeover": not self._yield}))
244
+ first = json.loads(await asyncio.wait_for(ws.recv(), 15))
245
+ if first.get("code") == "unlinked":
246
+ self._gone()
247
+ return
248
+ if first.get("code") == "in_use":
249
+ self._status("another copy of EDITH's body holds this PC's link; this copy waits "
250
+ f"and asks again every {int(self.replaced_wait)} s (stop one of them)", "waiting")
251
+ await asyncio.sleep(self.replaced_wait)
252
+ continue
253
+ if first.get("type") != "welcome":
254
+ self._status(f"the server refused this PC: {first.get('message', first.get('type'))}", "refused")
255
+ if "token" in str(first.get("message", "")):
256
+ await asyncio.sleep(60) # a wrong token won't fix itself in a second
257
+ continue
258
+ backoff = 1.0
259
+ self._yield = False
260
+ if isinstance(first.get("device_id"), str) and first["device_id"]:
261
+ self.device_id = first["device_id"] # the id the server knows this PC by
262
+ self._apply_config(first)
263
+ if self.backup_dir is not None and self._backup_task is None:
264
+ self._backup_task = asyncio.create_task(self._backup_copies())
265
+ if self.pc_backup and self._pc_backup_task is None:
266
+ self._pc_backup_task = asyncio.create_task(self._pc_backups())
267
+ self._status(f"linked to the server as {self.device_id} "
268
+ f"({len(pc_tool_names())} PC tools offered)", "linked")
269
+ code = await self._serve(ws)
270
+ if code == UNLINKED or self._unlinked:
271
+ self._gone()
272
+ return
273
+ if code == REPLACED:
274
+ self._yield = True
275
+ self._status("another connection took this PC's place (a second copy of EDITH's "
276
+ "body?); this copy waits until it stops, asking every "
277
+ f"{int(self.replaced_wait)} s", "waiting")
278
+ await asyncio.sleep(self.replaced_wait)
279
+ continue
280
+ except asyncio.CancelledError:
281
+ raise
282
+ except Exception as exc:
283
+ self._status(f"server unreachable ({exc or type(exc).__name__}); retrying", "unreachable")
284
+ await asyncio.sleep(backoff)
285
+ backoff = min(backoff * 2, 30.0)
286
+
287
+ async def _backup_copies(self) -> None:
288
+ """Once a day, the server's newest backup into backup_dir (a copy off the server)."""
289
+ while True:
290
+ newest = max((p.stat().st_mtime for p in self.backup_dir.glob("edith-backup-*.tar.gz")), default=0)
291
+ if time.time() - newest > 20 * 3600:
292
+ got = await fetch_server_backup(self.server_url, self.token, self.backup_dir)
293
+ if got is not None:
294
+ _log.info("backup: server copy saved as %s", got)
295
+ await asyncio.sleep(6 * 3600)
296
+
297
+ async def _pc_backups(self) -> None:
298
+ """This PC's folders into the server's vault, once the HUD switched some on (or
299
+ EDITH_BACKUP_FOLDERS names some): at night, or within a day and a half when the PC
300
+ was off at night. The work runs in a thread; tool calls keep flowing."""
301
+ from . import pc_backup
302
+
303
+ await asyncio.sleep(self.backup_settle) # let a fresh start settle
304
+ while True:
305
+ folders = pc_backup.chosen_folders(self.backup_choice)
306
+ if folders and pc_backup.due():
307
+ self._status(f"backup: backing up {len(folders)} folder(s) of this PC to the server")
308
+ try:
309
+ report = await asyncio.to_thread(pc_backup.run_backup, self.server_url, self.token,
310
+ folders=folders, device_id=self.device_id)
311
+ done = sum(1 for s in report["sources"].values() if not s.get("error"))
312
+ self._status(f"backup: {'done' if report.get('ok') else 'finished with problems'} "
313
+ f"({done}/{len(report['sources'])} sources)")
314
+ except Exception as exc:
315
+ self._status(f"backup failed: {exc}")
316
+ try: # every half hour, or at once when the HUD changes the choice
317
+ await asyncio.wait_for(self._backup_wake.wait(), timeout=1800)
318
+ except asyncio.TimeoutError:
319
+ pass
320
+ self._backup_wake.clear()
321
+
322
+ async def _serve(self, ws) -> int | None:
323
+ """Serve tool calls until the link closes; the close code, if the server sent one."""
324
+ tasks: set[asyncio.Task] = set()
325
+ lock = asyncio.Lock()
326
+
327
+ async def send(msg: dict) -> None:
328
+ async with lock:
329
+ await ws.send(json.dumps(msg, ensure_ascii=False, default=str))
330
+
331
+ async def handle_call(msg: dict) -> None:
332
+ name, args = str(msg.get("name", "")), msg.get("arguments", "{}")
333
+ async with self._limit:
334
+ try:
335
+ output = await asyncio.to_thread(self._run, name, args if isinstance(args, str) else json.dumps(args))
336
+ except Exception as exc: # never let a tool kill the link
337
+ output = json.dumps({"error": f"{name} failed on the PC: {exc}"})
338
+ try:
339
+ await send({"type": "tool_result", "id": msg.get("id"), "output": output})
340
+ except ConnectionClosed:
341
+ pass
342
+
343
+ try:
344
+ async for raw in ws:
345
+ try:
346
+ msg = json.loads(raw)
347
+ except ValueError:
348
+ continue
349
+ if not isinstance(msg, dict):
350
+ continue
351
+ mtype = msg.get("type")
352
+ if mtype == "tool_call":
353
+ task = asyncio.create_task(handle_call(msg))
354
+ tasks.add(task)
355
+ task.add_done_callback(tasks.discard)
356
+ elif mtype == "ping":
357
+ await send({"type": "pong"})
358
+ elif mtype == "config":
359
+ self._apply_config(msg)
360
+ elif mtype == "error":
361
+ if msg.get("code") == "unlinked":
362
+ self._unlinked = True
363
+ self._status(f"server says: {msg.get('message')}")
364
+ except ConnectionClosed as exc:
365
+ code = exc.rcvd.code if exc.rcvd else None
366
+ self._status(f"link closed ({code if code is not None else 'no close frame'}); reconnecting")
367
+ return code
368
+ finally:
369
+ for t in tasks:
370
+ t.cancel()
371
+ return None