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/__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())
|