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/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """EDITH — a real-time desktop voice assistant built on the OpenAI Realtime API."""
2
+
3
+ import sys as _sys
4
+
5
+ # EDITH prints emoji/box-drawing status lines. On Windows those crash with
6
+ # UnicodeEncodeError when stdout is a non-UTF-8 pipe; force UTF-8 with replacement.
7
+ for _stream in (_sys.stdout, _sys.stderr):
8
+ try:
9
+ _stream.reconfigure(encoding="utf-8", errors="replace")
10
+ except Exception:
11
+ pass
12
+
13
+ __version__ = "0.3.0"
edith/audio_io.py ADDED
@@ -0,0 +1,130 @@
1
+ """Microphone capture and speaker playback for the Realtime stream.
2
+
3
+ Design notes:
4
+ - The Realtime API speaks PCM16 / 24 kHz / mono. We capture and play in exactly
5
+ that format so there is no resampling.
6
+ - Capture runs in a sounddevice callback (a separate thread); we hand frames to the
7
+ asyncio loop via ``call_soon_threadsafe`` so the event loop is never blocked.
8
+ - Playback uses a callback that drains an internal byte buffer. ``flush_playback()``
9
+ clears that buffer instantly — this is how barge-in works: when the user starts
10
+ talking, we drop whatever EDITH was about to say.
11
+ - ``input_level`` / ``output_level`` (0..1 RMS) drive the GUI orb.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import threading
17
+
18
+ import numpy as np
19
+ import sounddevice as sd
20
+
21
+ from .config import BLOCK_FRAMES, CHANNELS, SAMPLE_RATE, SAMPLE_WIDTH
22
+
23
+
24
+ def _rms(pcm: bytes) -> float:
25
+ samples = np.frombuffer(pcm, dtype=np.int16)
26
+ if samples.size == 0:
27
+ return 0.0
28
+ return float(np.sqrt(np.mean((samples.astype(np.float32) / 32768.0) ** 2)))
29
+
30
+
31
+ def _rms_arr(samples: np.ndarray) -> float:
32
+ if samples.size == 0:
33
+ return 0.0
34
+ return float(np.sqrt(np.mean((samples.astype(np.float32) / 32768.0) ** 2)))
35
+
36
+
37
+ class AudioIO:
38
+ def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
39
+ self._loop = loop
40
+ self.mic_queue: asyncio.Queue[bytes] = asyncio.Queue()
41
+ self._in_stream: sd.RawInputStream | None = None
42
+ self._out_stream: sd.RawOutputStream | None = None
43
+ self._play_buf = bytearray()
44
+ self._play_lock = threading.Lock()
45
+ self.muted = False
46
+ self.input_level = 0.0
47
+ self.output_level = 0.0
48
+ # latest int16 frames, kept for the GUI's live spectrum (read-only from GUI thread)
49
+ self._last_in: np.ndarray | None = None
50
+ self._last_out: np.ndarray | None = None
51
+
52
+ # --- microphone (input) ---------------------------------------------------
53
+ def _on_mic(self, indata, frames, time_info, status) -> None: # noqa: ANN001
54
+ data = bytes(indata)
55
+ samples = np.frombuffer(data, dtype=np.int16)
56
+ self._last_in = samples
57
+ self.input_level = _rms_arr(samples)
58
+ if self.muted:
59
+ return
60
+ try:
61
+ self._loop.call_soon_threadsafe(self.mic_queue.put_nowait, data)
62
+ except RuntimeError:
63
+ pass # event loop already closed
64
+
65
+ # --- speaker (output) -----------------------------------------------------
66
+ def _on_play(self, outdata, frames, time_info, status) -> None: # noqa: ANN001
67
+ needed = frames * CHANNELS * SAMPLE_WIDTH
68
+ with self._play_lock:
69
+ chunk = bytes(self._play_buf[:needed])
70
+ del self._play_buf[:needed]
71
+ if len(chunk) < needed: # underrun -> pad with silence
72
+ chunk += b"\x00" * (needed - len(chunk))
73
+ samples = np.frombuffer(chunk, dtype=np.int16)
74
+ self._last_out = samples
75
+ self.output_level = _rms_arr(samples)
76
+ outdata[:] = chunk
77
+
78
+ # --- live spectrum (for the GUI HUD) --------------------------------------
79
+ def latest_spectrum(self, bands: int = 24) -> np.ndarray:
80
+ """Peak-normalized voice-band FFT magnitudes (0..1) of the most active stream."""
81
+ src = self._last_out if self.output_level >= self.input_level else self._last_in
82
+ if src is None or src.size == 0:
83
+ return np.zeros(bands, dtype=np.float32)
84
+ x = src.astype(np.float32) / 32768.0
85
+ windowed = x * np.hanning(x.size)
86
+ mag = np.abs(np.fft.rfft(windowed))
87
+ usable = mag[: max(1, mag.size // 2)] # lower half ~ voice energy
88
+ if usable.size < bands:
89
+ usable = np.pad(usable, (0, bands - usable.size))
90
+ grouped = np.array([float(g.mean()) for g in np.array_split(usable, bands)], dtype=np.float32)
91
+ peak = grouped.max()
92
+ return grouped / peak if peak > 0 else grouped
93
+
94
+ def play(self, pcm: bytes) -> None:
95
+ """Queue assistant audio for playback."""
96
+ with self._play_lock:
97
+ self._play_buf.extend(pcm)
98
+
99
+ def flush_playback(self) -> None:
100
+ """Drop all pending playback — called on barge-in to interrupt EDITH."""
101
+ with self._play_lock:
102
+ self._play_buf.clear()
103
+ self.output_level = 0.0
104
+
105
+ def is_speaking(self) -> bool:
106
+ with self._play_lock:
107
+ return len(self._play_buf) > 0
108
+
109
+ # --- lifecycle ------------------------------------------------------------
110
+ def start(self) -> None:
111
+ self._out_stream = sd.RawOutputStream(
112
+ samplerate=SAMPLE_RATE, channels=CHANNELS, dtype="int16",
113
+ blocksize=BLOCK_FRAMES, callback=self._on_play,
114
+ )
115
+ self._in_stream = sd.RawInputStream(
116
+ samplerate=SAMPLE_RATE, channels=CHANNELS, dtype="int16",
117
+ blocksize=BLOCK_FRAMES, callback=self._on_mic,
118
+ )
119
+ self._out_stream.start()
120
+ self._in_stream.start()
121
+
122
+ def stop(self) -> None:
123
+ for stream in (self._in_stream, self._out_stream):
124
+ if stream is not None:
125
+ try:
126
+ stream.stop()
127
+ stream.close()
128
+ except Exception:
129
+ pass
130
+ self._in_stream = self._out_stream = None
edith/backup.py ADDED
@@ -0,0 +1,260 @@
1
+ """Backups of EDITH's own state: memory, notes, reminders, sign-in, spend, Google tokens.
2
+
3
+ python -m edith.backup # make one now (the service also does it nightly)
4
+ python -m edith.backup list
5
+ python -m edith.backup restore FILE # stop the service first; a safety copy is made
6
+
7
+ A backup is one .tar.gz in EDITH_BACKUP_DIR (default ~/.edith_backups, private to the user).
8
+ The newest copy of each of the last 14 days, 8 weeks and 12 months is kept. The service makes
9
+ one every night and right after a start when the newest is over a day old; a PC linked to a
10
+ server brain fetches the newest one every day (GET /backup/latest), so a copy also lives off
11
+ the server. Logs and ~/.edith (the wake-word model, release keys) are not included.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import datetime as dt
16
+ import io
17
+ import json
18
+ import logging
19
+ import os
20
+ import socket
21
+ import sqlite3
22
+ import sys
23
+ import tarfile
24
+ import tempfile
25
+ import threading
26
+ import time
27
+ from pathlib import Path, PurePosixPath
28
+
29
+ _log = logging.getLogger("edith.backup")
30
+
31
+ FILES = (
32
+ ".edith_memory.json", ".edith_reminders.sqlite", ".edith_briefing.json",
33
+ ".edith_google_credentials.json", ".edith_google_token.json", ".edith_totp.json",
34
+ ".edith_web_sessions.json", ".edith_spend.json", ".edith_audit.jsonl", ".edith_service_token",
35
+ ".edith_devices.json",
36
+ )
37
+ DIRS = ("edith_notes",)
38
+ KEEP_DAILY, KEEP_WEEKLY, KEEP_MONTHLY = 14, 8, 12
39
+ PREFIX, SUFFIX = "edith-backup-", ".tar.gz"
40
+ _STAMP = "%Y-%m-%dT%H%M%SZ"
41
+
42
+
43
+ def backup_dir() -> Path:
44
+ configured = (os.getenv("EDITH_BACKUP_DIR") or "").strip()
45
+ return Path(configured).expanduser() if configured else Path.home() / ".edith_backups"
46
+
47
+
48
+ def _private_dir(path: Path) -> Path:
49
+ path.mkdir(parents=True, exist_ok=True)
50
+ try:
51
+ os.chmod(path, 0o700)
52
+ except OSError:
53
+ pass
54
+ return path
55
+
56
+
57
+ def _sqlite_copy(path: Path) -> bytes:
58
+ """A consistent copy of a database that may be in use (SQLite's own backup API)."""
59
+ fd, tmp = tempfile.mkstemp(suffix=".sqlite")
60
+ os.close(fd)
61
+ try:
62
+ src = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
63
+ dst = sqlite3.connect(tmp)
64
+ with dst:
65
+ src.backup(dst)
66
+ src.close()
67
+ dst.close()
68
+ return Path(tmp).read_bytes()
69
+ finally:
70
+ try:
71
+ os.unlink(tmp)
72
+ except OSError:
73
+ pass
74
+
75
+
76
+ def _add_bytes(tar: tarfile.TarFile, name: str, data: bytes, mtime: float) -> None:
77
+ info = tarfile.TarInfo(name)
78
+ info.size, info.mtime, info.mode = len(data), int(mtime), 0o600
79
+ tar.addfile(info, io.BytesIO(data))
80
+
81
+
82
+ def make_backup(home: Path | None = None, dest: Path | None = None,
83
+ now: dt.datetime | None = None) -> Path:
84
+ """Write one backup of EDITH's state under ``home`` into ``dest``; returns its path."""
85
+ home = home or Path.home()
86
+ dest = _private_dir(dest or backup_dir())
87
+ now = now or dt.datetime.now(dt.timezone.utc)
88
+ name = f"{PREFIX}{now.strftime(_STAMP)}{SUFFIX}"
89
+ part = dest / (name + ".part")
90
+ included = []
91
+ with tarfile.open(part, "w:gz") as tar:
92
+ for f in FILES:
93
+ p = home / f
94
+ if not p.is_file():
95
+ continue
96
+ try:
97
+ if p.suffix == ".sqlite":
98
+ _add_bytes(tar, f, _sqlite_copy(p), p.stat().st_mtime)
99
+ else:
100
+ tar.add(str(p), arcname=f, recursive=False)
101
+ included.append(f)
102
+ except (OSError, sqlite3.Error) as exc:
103
+ _log.warning("backup: skipped %s: %s", f, exc)
104
+ for d in DIRS:
105
+ root = home / d
106
+ if not root.is_dir():
107
+ continue
108
+ for q in sorted(root.rglob("*")):
109
+ if q.is_file() and not q.is_symlink():
110
+ arc = str(PurePosixPath(d, *q.relative_to(root).parts))
111
+ tar.add(str(q), arcname=arc, recursive=False)
112
+ included.append(arc)
113
+ manifest = {"made": now.isoformat(), "host": socket.gethostname(), "files": included}
114
+ _add_bytes(tar, "MANIFEST.json", json.dumps(manifest, indent=1).encode(), now.timestamp())
115
+ try:
116
+ os.chmod(part, 0o600)
117
+ except OSError:
118
+ pass
119
+ final = dest / name
120
+ part.replace(final)
121
+ prune(dest)
122
+ _log.info("backup: %s (%d items, %d bytes)", final.name, len(included), final.stat().st_size)
123
+ return final
124
+
125
+
126
+ def backups(dest: Path | None = None) -> list[tuple[dt.datetime, Path]]:
127
+ """Every backup in ``dest``, newest first."""
128
+ dest = dest or backup_dir()
129
+ out = []
130
+ if dest.is_dir():
131
+ for p in dest.glob(f"{PREFIX}*{SUFFIX}"):
132
+ try:
133
+ t = dt.datetime.strptime(p.name[len(PREFIX):-len(SUFFIX)], _STAMP).replace(tzinfo=dt.timezone.utc)
134
+ except ValueError:
135
+ continue
136
+ out.append((t, p))
137
+ return sorted(out, key=lambda x: x[0], reverse=True)
138
+
139
+
140
+ def latest(dest: Path | None = None) -> Path | None:
141
+ found = backups(dest)
142
+ return found[0][1] if found else None
143
+
144
+
145
+ def prune(dest: Path | None = None) -> list[Path]:
146
+ """Keep the newest backup of each of the last 14 days, 8 weeks and 12 months."""
147
+ items = backups(dest)
148
+ keep: set[Path] = set()
149
+
150
+ def pick(key, n):
151
+ seen = []
152
+ for t, p in items:
153
+ k = key(t)
154
+ if k in seen:
155
+ continue
156
+ seen.append(k)
157
+ keep.add(p)
158
+ if len(seen) >= n:
159
+ break
160
+
161
+ pick(lambda t: t.date(), KEEP_DAILY)
162
+ pick(lambda t: tuple(t.isocalendar())[:2], KEEP_WEEKLY)
163
+ pick(lambda t: (t.year, t.month), KEEP_MONTHLY)
164
+ removed = []
165
+ for _, p in items:
166
+ if p not in keep:
167
+ try:
168
+ p.unlink()
169
+ removed.append(p)
170
+ except OSError:
171
+ pass
172
+ return removed
173
+
174
+
175
+ def _allowed(name: str) -> bool:
176
+ parts = PurePosixPath(name).parts
177
+ if not parts or name.startswith("/") or ".." in parts or "\\" in name:
178
+ return False
179
+ return (len(parts) == 1 and parts[0] in FILES) or (len(parts) > 1 and parts[0] in DIRS)
180
+
181
+
182
+ def restore(archive: Path, home: Path | None = None, dest: Path | None = None) -> list[str]:
183
+ """Put a backup's files back into ``home``. A backup of the current state is made first.
184
+ Only EDITH's own files are written; anything else in the archive is ignored."""
185
+ home = home or Path.home()
186
+ make_backup(home, dest) # the state being replaced, in case this was the wrong file
187
+ written = []
188
+ with tarfile.open(archive, "r:gz") as tar:
189
+ for m in tar.getmembers():
190
+ if not m.isfile() or not _allowed(m.name):
191
+ continue
192
+ target = home.joinpath(*PurePosixPath(m.name).parts)
193
+ target.parent.mkdir(parents=True, exist_ok=True)
194
+ src = tar.extractfile(m)
195
+ if src is None:
196
+ continue
197
+ tmp = target.with_name(target.name + ".restore")
198
+ tmp.write_bytes(src.read())
199
+ tmp.replace(target)
200
+ written.append(m.name)
201
+ return written
202
+
203
+
204
+ def start_nightly(hour: int | None = None, extra=()) -> threading.Thread:
205
+ """A daemon thread that backs up every night at ``hour`` (local time, default 21:00 or
206
+ EDITH_BACKUP_HOUR), and once soon after start when the newest backup is over a day old."""
207
+ if hour is None:
208
+ try:
209
+ hour = int(os.getenv("EDITH_BACKUP_HOUR", "21"))
210
+ except ValueError:
211
+ hour = 21
212
+
213
+ def run_once() -> None:
214
+ try:
215
+ make_backup()
216
+ except Exception as exc: # a failed backup must never take the service down
217
+ _log.warning("backup failed: %s", exc)
218
+ for job in extra: # e.g. the vault's sweep
219
+ try:
220
+ job()
221
+ except Exception as exc:
222
+ _log.warning("nightly job failed: %s", exc)
223
+
224
+ def loop() -> None:
225
+ time.sleep(600) # let a fresh start settle first
226
+ newest = backups()
227
+ if not newest or (dt.datetime.now(dt.timezone.utc) - newest[0][0]).total_seconds() > 86400:
228
+ run_once()
229
+ while True:
230
+ now = dt.datetime.now()
231
+ nxt = now.replace(hour=hour % 24, minute=0, second=0, microsecond=0)
232
+ if nxt <= now:
233
+ nxt += dt.timedelta(days=1)
234
+ time.sleep(max(60.0, (nxt - now).total_seconds()))
235
+ run_once()
236
+
237
+ t = threading.Thread(target=loop, name="edith-backup", daemon=True)
238
+ t.start()
239
+ return t
240
+
241
+
242
+ def main(argv: list[str] | None = None) -> int:
243
+ args = list(sys.argv[1:] if argv is None else argv)
244
+ cmd = args[0] if args else "now"
245
+ if cmd == "now":
246
+ print(make_backup())
247
+ elif cmd == "list":
248
+ for t, p in backups():
249
+ print(f"{t:%Y-%m-%d %H:%M} UTC {p.stat().st_size:>9} bytes {p}")
250
+ elif cmd == "restore" and len(args) == 2:
251
+ for name in restore(Path(args[1])):
252
+ print("restored", name)
253
+ else:
254
+ print(__doc__)
255
+ return 2
256
+ return 0
257
+
258
+
259
+ if __name__ == "__main__":
260
+ raise SystemExit(main())
edith/body/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """edith.body — this PC as a body of the server brain.
2
+
3
+ python -m edith.body
4
+
5
+ Connects out to the EDITH server (EDITH_SERVER_URL) with the pairing token, announces the
6
+ PC's tools, and runs the ones the brain asks for: apps, windows, files, the screen, the
7
+ keyboard, the shell, the computer agent. The brain, the memory and the budget live on the
8
+ server; the phone and the browser HUD reach this PC through it from anywhere.
9
+
10
+ No OpenAI key is needed here. Tools that call a model (see_screen, read_document,
11
+ delegate_task, the computer agent) do so through the server: the OpenAI client is pointed at
12
+ ``EDITH_SERVER_URL/v1`` with the pairing token as its key, and the server forwards the call
13
+ with its own key and meters the spend against the one daily budget.
14
+
15
+ The PC brain mode (``python -m edith.service`` on the PC, reached over the LAN or the relay)
16
+ still exists for a PC that should work without the server.
17
+ """
edith/body/__main__.py ADDED
@@ -0,0 +1,116 @@
1
+ """Run this PC as a body of the server brain:
2
+
3
+ python -m edith.body link and serve
4
+ python -m edith.body link link this PC with a code from Google Authenticator
5
+ python -m edith.body unlink forget that link (the server drops this PC's key)
6
+ python -m edith.body status what this PC is linked to
7
+
8
+ Two ways to be linked. The PC EDITH was set up on has EDITH_SERVER_URL and the pairing token
9
+ (EDITH_SERVICE_TOKEN, or ~/.edith_service_token) in .env. Any other PC links with a code
10
+ instead (`link`, or the EDITH app from the installer): it gets a key of its own, kept in
11
+ ~/.edith_body.json. No OpenAI key is needed on either.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import getpass
17
+ import logging
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ from ..config import settings
22
+ from ..legacy_migration import migrate
23
+ from ..logging_setup import ensure_streams, setup_logging
24
+ from . import link as linking
25
+ from .client import Body, pairing_token, pc_tool_names, point_model_calls_at_server
26
+
27
+ _log = logging.getLogger("edith.body")
28
+
29
+
30
+ def credentials() -> tuple[str, str, str]:
31
+ """(server, key, how): EDITH_SERVER_URL with the pairing token ("token"), else the link
32
+ this PC made with a code ("code"), else ("", "", "")."""
33
+ server = (getattr(settings, "server_url", None) or "").strip().rstrip("/")
34
+ token = pairing_token(settings)
35
+ if server and token:
36
+ return server, token, "token"
37
+ cfg = linking.load()
38
+ if linking.linked(cfg):
39
+ return str(cfg["server"]), str(cfg["token"]), "code"
40
+ return "", "", ""
41
+
42
+
43
+ def _cli(command: str) -> int:
44
+ cfg = linking.load()
45
+ if command == "status":
46
+ server, _, how = credentials()
47
+ if not server:
48
+ print("This PC is not linked. Link it: python -m edith.body link")
49
+ elif how == "token":
50
+ print(f"Linked to {server} with the pairing token (.env).")
51
+ else:
52
+ print(f"Linked to {server} as {cfg.get('device_id')} with its own key (~/.edith_body.json).")
53
+ return 0
54
+ if command == "unlink":
55
+ if not linking.linked(cfg):
56
+ print("This PC has no code-made link to forget.")
57
+ return 0
58
+ dropped = linking.unlink()
59
+ print("Unlinked." + ("" if dropped else " The server couldn't be told: remove this PC in the HUD (Bodies)."))
60
+ return 0
61
+ # link
62
+ default = str(cfg.get("server") or getattr(settings, "server_url", None) or "")
63
+ try:
64
+ server = input(f"EDITH server [{default}]: " if default else "EDITH server (e.g. edith.example.com): ").strip() or default
65
+ code = getpass.getpass("Code from Google Authenticator (6 digits, hidden): ")
66
+ except (EOFError, KeyboardInterrupt):
67
+ print()
68
+ return 1
69
+ try:
70
+ made = linking.link(server, code)
71
+ except linking.LinkError as exc:
72
+ print(f"❌ {exc}")
73
+ return 1
74
+ print(f"✅ Linked to {made['server']} as {made['device_id']}. Start the body: python -m edith.body")
75
+ return 0
76
+
77
+
78
+ def main(argv: list[str] | None = None) -> int:
79
+ argv = sys.argv[1:] if argv is None else argv
80
+ ensure_streams(Path.home() / ".edith_body.out.log")
81
+ migrate()
82
+ setup_logging()
83
+ if argv and argv[0] in ("link", "unlink", "status"):
84
+ return _cli(argv[0])
85
+ if argv:
86
+ print(__doc__)
87
+ return 2
88
+ server, key, how = credentials()
89
+ if not server:
90
+ print("❌ This PC is not linked to an EDITH server. Link it with a code from Google Authenticator:\n"
91
+ " python -m edith.body link\n"
92
+ " (or, on the PC EDITH was set up on, put EDITH_SERVER_URL and the pairing token in .env)")
93
+ return 1
94
+ point_model_calls_at_server(server, key)
95
+ print(
96
+ "\n EDITH body — this PC, linked to the server brain\n"
97
+ f" server: {server} ({'pairing token' if how == 'token' else 'its own key, linked with a code'})\n"
98
+ f" offering: {len(pc_tool_names())} PC tools (model calls go through the server)\n"
99
+ " Ctrl+C to stop.\n"
100
+ )
101
+ _log.info("body starting for %s with %d tools (%s)", server, len(pc_tool_names()), how)
102
+ # Only the PC on the pairing token keeps a daily copy of the server's own backup (it holds
103
+ # the sign-in secrets; a PC's key can't fetch it). Every PC backs its folders up into the
104
+ # vault once the HUD switches them on (Bodies > PCs).
105
+ copies = Path.home() / "EDITH-backups" / "server" if how == "token" else None
106
+ body = Body(server, key, backup_dir=copies, pc_backup=True,
107
+ on_unlinked=(lambda: linking.forget(token=key)) if how == "code" else None)
108
+ try:
109
+ asyncio.run(body.run_forever())
110
+ except KeyboardInterrupt:
111
+ pass
112
+ return 0
113
+
114
+
115
+ if __name__ == "__main__":
116
+ sys.exit(main())