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.
- edith/__init__.py +13 -0
- edith/audio_io.py +130 -0
- edith/backup.py +260 -0
- edith/body/__init__.py +17 -0
- edith/body/__main__.py +116 -0
- edith/body/cli.py +224 -0
- edith/body/client.py +371 -0
- edith/body/desktop.py +478 -0
- edith/body/identity.py +10 -0
- edith/body/link.py +206 -0
- edith/body/pc_backup.py +347 -0
- edith/config.py +205 -0
- edith/gui.py +610 -0
- edith/gui_classic.py +352 -0
- edith/hud/__init__.py +11 -0
- edith/hud/easing.py +72 -0
- edith/hud/fonts.py +69 -0
- edith/hud/orb_renderer.py +66 -0
- edith/hud/textures.py +39 -0
- edith/legacy_migration.py +185 -0
- edith/logging_setup.py +53 -0
- edith/main.py +91 -0
- edith/pricing.py +143 -0
- edith/prompts.py +117 -0
- edith/realtime_client.py +685 -0
- edith/relay/__init__.py +12 -0
- edith/relay/__main__.py +4 -0
- edith/relay/bridge.py +333 -0
- edith/relay/crypto.py +186 -0
- edith/relay/server.py +147 -0
- edith/service/__init__.py +18 -0
- edith/service/__main__.py +130 -0
- edith/service/app.py +1180 -0
- edith/service/approvals.py +119 -0
- edith/service/brain.py +371 -0
- edith/service/devicebook.py +212 -0
- edith/service/devices.py +200 -0
- edith/service/phone_agent.py +266 -0
- edith/service/protocol.py +99 -0
- edith/service/tokens.py +32 -0
- edith/service/totp.py +61 -0
- edith/service/webauth.py +238 -0
- edith/session_manager.py +99 -0
- edith/tools/__init__.py +1 -0
- edith/tools/briefing.py +115 -0
- edith/tools/clock.py +13 -0
- edith/tools/computer_agent.py +419 -0
- edith/tools/delegate.py +99 -0
- edith/tools/documents.py +76 -0
- edith/tools/external_cost.py +36 -0
- edith/tools/file_ops.py +189 -0
- edith/tools/google_workspace.py +205 -0
- edith/tools/health_watch.py +70 -0
- edith/tools/home_assistant.py +142 -0
- edith/tools/llm.py +28 -0
- edith/tools/memory.py +205 -0
- edith/tools/news_finance.py +137 -0
- edith/tools/notes.py +50 -0
- edith/tools/pc_control.py +474 -0
- edith/tools/registry.py +942 -0
- edith/tools/reminders.py +129 -0
- edith/tools/spotify_music.py +141 -0
- edith/tools/system_control.py +197 -0
- edith/tools/system_toggles.py +261 -0
- edith/tools/task_manager.py +137 -0
- edith/tools/telegram_notify.py +36 -0
- edith/tools/vision.py +109 -0
- edith/tools/weather.py +57 -0
- edith/tools/web_search.py +65 -0
- edith/tools/whatsapp.py +97 -0
- edith/tools/window_control.py +146 -0
- edith/vault.py +395 -0
- edith/wake_word.py +159 -0
- edith_pc-0.3.0.dist-info/METADATA +75 -0
- edith_pc-0.3.0.dist-info/RECORD +79 -0
- edith_pc-0.3.0.dist-info/WHEEL +5 -0
- edith_pc-0.3.0.dist-info/entry_points.txt +5 -0
- edith_pc-0.3.0.dist-info/licenses/LICENSE +21 -0
- 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
|