wraithd 0.0.1__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.
- wraithd/__init__.py +5 -0
- wraithd/__main__.py +5 -0
- wraithd/audit.py +251 -0
- wraithd/channels/__init__.py +1 -0
- wraithd/channels/base.py +318 -0
- wraithd/channels/conversation.py +26 -0
- wraithd/channels/telegram.py +238 -0
- wraithd/channels/terminal.py +111 -0
- wraithd/cli.py +319 -0
- wraithd/config.py +414 -0
- wraithd/core/__init__.py +13 -0
- wraithd/core/compaction.py +64 -0
- wraithd/core/delegate.py +116 -0
- wraithd/core/disclosure.py +261 -0
- wraithd/core/egress.py +115 -0
- wraithd/core/events.py +150 -0
- wraithd/core/gate.py +748 -0
- wraithd/core/hooks.py +59 -0
- wraithd/core/inspector.py +180 -0
- wraithd/core/loop.py +99 -0
- wraithd/core/reminders.py +279 -0
- wraithd/core/render.py +91 -0
- wraithd/core/replay.py +87 -0
- wraithd/core/secrets.py +87 -0
- wraithd/core/stop.py +90 -0
- wraithd/core/tokens.py +303 -0
- wraithd/core/tool_results.py +57 -0
- wraithd/core/views.py +93 -0
- wraithd/daemon/__init__.py +4 -0
- wraithd/daemon/api.py +537 -0
- wraithd/daemon/app.py +111 -0
- wraithd/daemon/commands.py +150 -0
- wraithd/daemon/heartbeat.py +213 -0
- wraithd/daemon/jobs.py +88 -0
- wraithd/daemon/scheduler.py +51 -0
- wraithd/daemon/security.py +112 -0
- wraithd/daemon/service.py +316 -0
- wraithd/doctor.py +184 -0
- wraithd/evals/__init__.py +0 -0
- wraithd/evals/__main__.py +75 -0
- wraithd/evals/meter.py +129 -0
- wraithd/evals/runner.py +451 -0
- wraithd/evals/scripted.py +210 -0
- wraithd/evals/site.py +50 -0
- wraithd/evals/tasks.py +302 -0
- wraithd/evals/world.py +207 -0
- wraithd/mcp/__init__.py +4 -0
- wraithd/mcp/client.py +194 -0
- wraithd/mcp/commands.py +262 -0
- wraithd/mcp/config.py +210 -0
- wraithd/mcp/pinning.py +245 -0
- wraithd/memory/__init__.py +6 -0
- wraithd/memory/distill.py +119 -0
- wraithd/memory/notes.py +135 -0
- wraithd/memory/review.py +98 -0
- wraithd/memory/tools.py +207 -0
- wraithd/memory/vault.py +233 -0
- wraithd/onboard.py +337 -0
- wraithd/providers.py +230 -0
- wraithd/rename.py +130 -0
- wraithd/review/__init__.py +1 -0
- wraithd/review/api.py +70 -0
- wraithd/review/cli.py +91 -0
- wraithd/review/queue.py +262 -0
- wraithd/run.py +392 -0
- wraithd/sandbox/__init__.py +90 -0
- wraithd/sandbox/audit.py +69 -0
- wraithd/sandbox/docker.py +87 -0
- wraithd/sandbox/settings.py +61 -0
- wraithd/schedules.py +112 -0
- wraithd/skills/__init__.py +4 -0
- wraithd/skills/library/inbox-triage/SKILL.md +24 -0
- wraithd/skills/library/meeting-prep/SKILL.md +20 -0
- wraithd/skills/library/morning-brief/SKILL.md +20 -0
- wraithd/skills/library/research-report/SKILL.md +16 -0
- wraithd/skills/library/research-report/report-template.md +16 -0
- wraithd/skills/library/skill-writer/SKILL.md +19 -0
- wraithd/skills/library/weekly-review/SKILL.md +18 -0
- wraithd/skills/registry.py +227 -0
- wraithd/skills/review.py +166 -0
- wraithd/skills/tools.py +138 -0
- wraithd/spill/__init__.py +91 -0
- wraithd/store/__init__.py +22 -0
- wraithd/store/base.py +184 -0
- wraithd/store/modules.py +115 -0
- wraithd/store/sqlite.py +383 -0
- wraithd/tools/__init__.py +95 -0
- wraithd/tools/base.py +46 -0
- wraithd/tools/browser.py +485 -0
- wraithd/tools/documents.py +214 -0
- wraithd/tools/files.py +350 -0
- wraithd/tools/notify.py +45 -0
- wraithd/tools/schedules.py +64 -0
- wraithd/tools/shell.py +97 -0
- wraithd/tools/web.py +426 -0
- wraithd/ui/assets/index-B9FtiVGr.css +1 -0
- wraithd/ui/assets/index-E7uVIjF7.js +15 -0
- wraithd/ui/assets/instrument-serif-latin-400-italic-DKMiL14s.woff2 +0 -0
- wraithd/ui/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- wraithd/ui/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- wraithd/ui/assets/inter-tight-latin-wght-normal-DX-nOvPD.woff2 +0 -0
- wraithd/ui/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- wraithd/ui/assets/server-DdTQ3pZ0.js +22 -0
- wraithd/ui/favicon.svg +1 -0
- wraithd/ui/index.html +17 -0
- wraithd/update.py +59 -0
- wraithd/voice.py +157 -0
- wraithd-0.0.1.dist-info/METADATA +161 -0
- wraithd-0.0.1.dist-info/RECORD +112 -0
- wraithd-0.0.1.dist-info/WHEEL +4 -0
- wraithd-0.0.1.dist-info/entry_points.txt +2 -0
- wraithd-0.0.1.dist-info/licenses/LICENSE +21 -0
wraithd/__init__.py
ADDED
wraithd/__main__.py
ADDED
wraithd/audit.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""
|
|
2
|
+
`wraithd audit`: the live setup checked against the security rules (docs/ec2-to-laptop.md). One line per rule,
|
|
3
|
+
pass or FAIL with a fix, and a few info lines for standing permissions worth knowing about. Exit code 1 on a FAIL.
|
|
4
|
+
|
|
5
|
+
Like doctor it only reads (the config, file modes, the store) and never changes anything. doctor runs it too and
|
|
6
|
+
reports one line for all of it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import ipaddress
|
|
11
|
+
import os
|
|
12
|
+
import stat
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from urllib.parse import urlsplit
|
|
15
|
+
|
|
16
|
+
from wraithd.config import ConfigError, Home, load_config
|
|
17
|
+
from wraithd.core import gate
|
|
18
|
+
from wraithd.core.secrets import redact
|
|
19
|
+
from wraithd.daemon import app as daemon_app
|
|
20
|
+
from wraithd.daemon.security import TOKEN_FILE
|
|
21
|
+
from wraithd.doctor import FAIL, INFO, OK, Check, render
|
|
22
|
+
|
|
23
|
+
MIN_TOKEN_LENGTH = 32
|
|
24
|
+
MAX_LISTED = 3 # offending paths named per rule
|
|
25
|
+
DB_FILES = ('', '-wal', '-shm') # state.db and SQLite's side files: the event log, the review queue, the pins
|
|
26
|
+
TAILNET = '.ts.net'
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _shown(home: Home, paths: list[Path]) -> str:
|
|
30
|
+
names = [str(path.relative_to(home.root)) for path in paths[:MAX_LISTED]]
|
|
31
|
+
return ', '.join(names) + (f' and {len(paths) - MAX_LISTED} more' if len(paths) > MAX_LISTED else '')
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _loose(home: Home) -> tuple[list[Path], list[Path]]:
|
|
35
|
+
"""Paths inside the state folder that others can write to, and paths another account owns. Symlinks aren't
|
|
36
|
+
followed: what they point to isn't the folder's."""
|
|
37
|
+
writable, foreign = [], []
|
|
38
|
+
uid = os.getuid()
|
|
39
|
+
for folder, folders, files in os.walk(home.root):
|
|
40
|
+
for name in folders + files:
|
|
41
|
+
path = Path(folder) / name
|
|
42
|
+
info = path.lstat()
|
|
43
|
+
if stat.S_ISLNK(info.st_mode):
|
|
44
|
+
continue
|
|
45
|
+
if info.st_mode & 0o022:
|
|
46
|
+
writable.append(path)
|
|
47
|
+
if info.st_uid != uid:
|
|
48
|
+
foreign.append(path)
|
|
49
|
+
return writable, foreign
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _local_origin(origin: str) -> bool:
|
|
53
|
+
"""http on localhost or 127.0.0.1 with a real port, and nothing else (config only checks the prefix)."""
|
|
54
|
+
parts = urlsplit(origin)
|
|
55
|
+
try:
|
|
56
|
+
port = parts.port
|
|
57
|
+
except ValueError:
|
|
58
|
+
return False
|
|
59
|
+
return (parts.scheme == 'http' and parts.hostname in ('localhost', '127.0.0.1') and port is not None
|
|
60
|
+
and not parts.path.strip('/') and not parts.username)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def checks(home: Home) -> list[Check]:
|
|
64
|
+
found = []
|
|
65
|
+
|
|
66
|
+
def add(state: str, text: str, fix: str = '') -> None:
|
|
67
|
+
found.append(Check(state, text, fix))
|
|
68
|
+
|
|
69
|
+
# --- the state folder: memory, the event log, the token, the config -------------------------------------
|
|
70
|
+
if not home.root.is_dir():
|
|
71
|
+
add(FAIL, f'No state folder at {home.root}', 'wraithd onboard')
|
|
72
|
+
return found
|
|
73
|
+
info = home.root.stat()
|
|
74
|
+
mode = stat.S_IMODE(info.st_mode)
|
|
75
|
+
add(OK if mode & 0o077 == 0 and info.st_uid == os.getuid() else FAIL,
|
|
76
|
+
f'{home.root} is yours alone (mode {mode:o})', f'chmod 700 {home.root}')
|
|
77
|
+
writable, foreign = _loose(home)
|
|
78
|
+
add(OK if not writable else FAIL,
|
|
79
|
+
'Nothing in it is writable by other accounts' if not writable else f'Writable by other accounts: '
|
|
80
|
+
f'{_shown(home, writable)}', f'chmod -R go-w {home.root}')
|
|
81
|
+
add(OK if not foreign else FAIL,
|
|
82
|
+
'Everything in it belongs to you' if not foreign else f'Owned by another account: {_shown(home, foreign)}',
|
|
83
|
+
f'chown -R {os.getuid()} {home.root}')
|
|
84
|
+
|
|
85
|
+
# --- the local API ----------------------------------------------------------------------------------------
|
|
86
|
+
loopback = ipaddress.ip_address(daemon_app.HOST).is_loopback
|
|
87
|
+
add(OK if loopback else FAIL, f'The API binds to {daemon_app.HOST} only' if loopback else
|
|
88
|
+
f'The API binds to {daemon_app.HOST}, which other machines can reach', 'reinstall wraithd')
|
|
89
|
+
token = home.root / TOKEN_FILE
|
|
90
|
+
if token.exists():
|
|
91
|
+
mode = stat.S_IMODE(token.stat().st_mode)
|
|
92
|
+
strong = len(token.read_text().strip()) >= MIN_TOKEN_LENGTH
|
|
93
|
+
add(OK if mode & 0o077 == 0 and strong else FAIL,
|
|
94
|
+
f'API token is set and private (mode {mode:o})' if strong else 'API token is too short',
|
|
95
|
+
f'chmod 600 {token}' if strong else f'rm {token} and restart the daemon (it makes a new one)')
|
|
96
|
+
else:
|
|
97
|
+
add(OK, 'API token: not made yet (the daemon makes one, mode 600, before it serves anything)')
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
config = load_config(home)
|
|
101
|
+
except ConfigError as e:
|
|
102
|
+
add(FAIL, f'Settings don\'t load, so the rules below can\'t be checked: {e}', f'edit {home.config_path}')
|
|
103
|
+
return found
|
|
104
|
+
|
|
105
|
+
if not config.dev_origin:
|
|
106
|
+
add(OK, 'Only the daemon\'s own origin may call the API')
|
|
107
|
+
elif _local_origin(config.dev_origin):
|
|
108
|
+
add(INFO, f'One more origin may call the API (with the token): {config.dev_origin}')
|
|
109
|
+
else:
|
|
110
|
+
add(FAIL, f'dev_origin {config.dev_origin} isn\'t on this machine: that site\'s pages may call the API',
|
|
111
|
+
f'remove dev_origin from [daemon] in {home.config_path}')
|
|
112
|
+
|
|
113
|
+
# --- the shell --------------------------------------------------------------------------------------------
|
|
114
|
+
gated = gate.classify_tool('shell_run') == gate.ToolAccess.WRITE and 'shell_run' in gate.NEVER_UNATTENDED
|
|
115
|
+
if not config.shell_enabled:
|
|
116
|
+
add(OK, 'Shell: off')
|
|
117
|
+
else:
|
|
118
|
+
add(OK if gated else FAIL, 'Shell: on, every command asks first and never runs unattended' if gated else
|
|
119
|
+
'Shell: on, and not every command asks first', f'set shell_enabled = false in {home.config_path}')
|
|
120
|
+
|
|
121
|
+
# --- keys -------------------------------------------------------------------------------------------------
|
|
122
|
+
_, kinds = redact(home.config_path.read_text()) if home.config_path.exists() else ('', [])
|
|
123
|
+
add(OK if not kinds else FAIL, 'No keys in config.toml (they belong in the keychain)' if not kinds else
|
|
124
|
+
f'config.toml holds what looks like a key ({", ".join(kinds)})',
|
|
125
|
+
f'remove it from {home.config_path}; keep it with wraithd key set <provider> or mcp add --env')
|
|
126
|
+
|
|
127
|
+
# --- reach: what the agent can open on its own (docs/lockdown.md) -------------------------------------------
|
|
128
|
+
from wraithd.tools import choose_search
|
|
129
|
+
|
|
130
|
+
browser = config.section('browser')['enabled']
|
|
131
|
+
add(INFO, f'Web: search {choose_search(config).backend or "off"}, page reading '
|
|
132
|
+
f'{"on" if config.web_read else "off"}, browser {"on" if browser else "off"}')
|
|
133
|
+
for server in config.mcp_servers:
|
|
134
|
+
add(INFO, f'MCP {server.name}: ' + (f'only {", ".join(server.tools)}' if server.tools else
|
|
135
|
+
'every tool it lists (no tools list)'))
|
|
136
|
+
|
|
137
|
+
# --- standing permissions: not problems, but the owner should know they exist -----------------------------
|
|
138
|
+
allowed = [f'{server.name} {tool}' for server in config.mcp_servers for tool in server.always_allow]
|
|
139
|
+
if allowed:
|
|
140
|
+
add(INFO, f'Always allowed (run without asking, but only to recipients you wrote): {", ".join(allowed)}. '
|
|
141
|
+
'A tool the server marks destructive asks anyway.')
|
|
142
|
+
if home.db_path.exists():
|
|
143
|
+
from wraithd.store import SQLiteStore
|
|
144
|
+
|
|
145
|
+
store = SQLiteStore(home.db_path)
|
|
146
|
+
try:
|
|
147
|
+
active = len(store.list_schedules())
|
|
148
|
+
finally:
|
|
149
|
+
store.close()
|
|
150
|
+
if active:
|
|
151
|
+
add(INFO, f'{active} schedule{"s" if active > 1 else ""} run{"" if active > 1 else "s"} unattended: '
|
|
152
|
+
'wraithd schedules list')
|
|
153
|
+
# New rules: each worker adds `found += its_rules(home, config)` (a list of Check) inside its block only
|
|
154
|
+
# >>> security
|
|
155
|
+
found += security_rules(home, config)
|
|
156
|
+
# <<< security
|
|
157
|
+
# >>> efficiency
|
|
158
|
+
# <<< efficiency
|
|
159
|
+
# >>> capabilities
|
|
160
|
+
# <<< capabilities
|
|
161
|
+
# >>> reach
|
|
162
|
+
# <<< reach
|
|
163
|
+
# >>> evals
|
|
164
|
+
# <<< evals
|
|
165
|
+
# >>> sandbox
|
|
166
|
+
from wraithd.sandbox.audit import sandbox_rules
|
|
167
|
+
|
|
168
|
+
found += sandbox_rules(home, config)
|
|
169
|
+
# <<< sandbox
|
|
170
|
+
return found
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def remote_hosts(config) -> list[str]:
|
|
174
|
+
"""The [remote] allowed_hosts entries (reach's daemon.security.remote_hosts; none until it exists)."""
|
|
175
|
+
from wraithd.daemon import security
|
|
176
|
+
|
|
177
|
+
found = getattr(security, 'remote_hosts', None)
|
|
178
|
+
return list(found(config)) if found else []
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _remote_host_problem(host: str) -> str:
|
|
182
|
+
"""Why a remote host isn't a tailnet name, or '' if it is one."""
|
|
183
|
+
name = host.strip().lower().rstrip('.')
|
|
184
|
+
with contextlib.suppress(ValueError):
|
|
185
|
+
ipaddress.ip_address(name.strip('[]'))
|
|
186
|
+
return 'is an IP address'
|
|
187
|
+
if name in ('localhost', '127.0.0.1') or name.endswith('.localhost'):
|
|
188
|
+
return 'is this machine'
|
|
189
|
+
if not name.endswith(TAILNET) or name == TAILNET.lstrip('.'):
|
|
190
|
+
return f'isn\'t a tailnet name (*{TAILNET})'
|
|
191
|
+
return ''
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def security_rules(home: Home, config) -> list[Check]:
|
|
195
|
+
"""The review queue's and the pins' storage, pinned MCP tools, remote access (docs/security.md)."""
|
|
196
|
+
found = []
|
|
197
|
+
|
|
198
|
+
def add(state: str, text: str, fix: str = '') -> None:
|
|
199
|
+
found.append(Check(state, text, fix))
|
|
200
|
+
|
|
201
|
+
# --- state.db holds the review queue (third-party text) and the pins: the owner's alone ----------------------
|
|
202
|
+
exposed = [Path(f'{home.db_path}{suffix}') for suffix in DB_FILES]
|
|
203
|
+
exposed = [path for path in exposed if path.exists() and stat.S_IMODE(path.stat().st_mode) & 0o077]
|
|
204
|
+
add(OK if not exposed else FAIL, 'The store (review queue, pins, event log) is readable by you alone' if not exposed
|
|
205
|
+
else f'Other accounts can open the store: {_shown(home, exposed)}',
|
|
206
|
+
f'chmod 600 {" ".join(str(path) for path in exposed)}')
|
|
207
|
+
|
|
208
|
+
# --- always-allowed MCP tools are pinned to what they were when allowed --------------------------------------
|
|
209
|
+
allowed = [(server.name, tool) for server in config.mcp_servers for tool in server.always_allow]
|
|
210
|
+
rows = {}
|
|
211
|
+
if home.db_path.exists():
|
|
212
|
+
from wraithd.mcp.pinning import Pins
|
|
213
|
+
from wraithd.review.queue import ReviewQueue
|
|
214
|
+
from wraithd.store import SQLiteStore
|
|
215
|
+
|
|
216
|
+
store = SQLiteStore(home.db_path)
|
|
217
|
+
try:
|
|
218
|
+
rows = {(row['server'], row['tool']): row for row in Pins(store).rows()}
|
|
219
|
+
pending = ReviewQueue(store).pending_count()
|
|
220
|
+
finally:
|
|
221
|
+
store.close()
|
|
222
|
+
if pending:
|
|
223
|
+
add(INFO, f'{pending} item{"s" if pending > 1 else ""} wait{"" if pending > 1 else "s"} for your review: '
|
|
224
|
+
'wraithd review list')
|
|
225
|
+
unpinned = [f'{server} {tool}' for server, tool in allowed
|
|
226
|
+
if not (rows.get((server, tool)) or {}).get('allowed_hash')]
|
|
227
|
+
changed = [f'{server} {tool}' for server, tool in allowed
|
|
228
|
+
if (row := rows.get((server, tool))) and row['allowed_hash'] and row['allowed_hash'] != row['seen_hash']]
|
|
229
|
+
if allowed:
|
|
230
|
+
add(OK if not unpinned else FAIL, 'Always-allowed MCP tools are pinned to what you allowed' if not unpinned
|
|
231
|
+
else f'Always allowed but not pinned, so they ask (re-allow needed): {", ".join(unpinned)}',
|
|
232
|
+
'wraithd mcp allow <server> <tool> for each (it checks the tool and pins it)')
|
|
233
|
+
add(OK if not changed else FAIL, 'No always-allowed MCP tool changed since you allowed it' if not changed
|
|
234
|
+
else f'Changed since you allowed them, so they ask until you review: {", ".join(changed)}',
|
|
235
|
+
'wraithd review list')
|
|
236
|
+
|
|
237
|
+
# --- remote access: only through the tailnet, and still with the token -------------------------------------
|
|
238
|
+
for host in remote_hosts(config):
|
|
239
|
+
problem = _remote_host_problem(host)
|
|
240
|
+
if problem:
|
|
241
|
+
add(FAIL, f'Remote access host {host} {problem}: anyone who can reach it could reach the API',
|
|
242
|
+
'use your tailnet name from `tailscale status`')
|
|
243
|
+
else:
|
|
244
|
+
add(INFO, f'Reachable remotely at https://{host.strip().rstrip(".")} (token still required)')
|
|
245
|
+
return found
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def main(home: Home) -> int:
|
|
249
|
+
found = checks(home)
|
|
250
|
+
print(render(found))
|
|
251
|
+
return 1 if any(check.state == FAIL for check in found) else 0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Where the owner talks to the agent. The terminal now; Telegram and the web UI with the daemon (Phase 5)."""
|
wraithd/channels/base.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""
|
|
2
|
+
What every phone channel shares, so the rules live in one place and a new channel only writes its transport:
|
|
3
|
+
|
|
4
|
+
- Pairing: a 6-digit code (`wraithd <channel> pair`) the owner sends as `/pair <code>` within 10 minutes; that
|
|
5
|
+
chat becomes the owner's. Five wrong tries and the code is gone.
|
|
6
|
+
- The owner's messages are runs; anyone else's get no run, no model call, no reply and no download, except
|
|
7
|
+
`/pair <code>`. Each such chat is logged as the channel's ignored event (chat and name, never the text), at most
|
|
8
|
+
once an hour per chat.
|
|
9
|
+
- Attachments from the owner: a photo is image input (saved instead when the model can't see), a voice note is
|
|
10
|
+
transcribed (voice.py), any other file is saved to workspace/files/inbox/.
|
|
11
|
+
- Approvals are a prompt with choices (buttons, or numbered replies on a channel without them); only the owner's
|
|
12
|
+
answer counts.
|
|
13
|
+
|
|
14
|
+
A channel subclass turns its service's updates into Incoming and Decision, and implements send, fetch,
|
|
15
|
+
acknowledge and close_prompt. Settings are stored under '<name>.<key>' (telegram.owner_chat ...).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import base64
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import secrets
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from datetime import UTC, datetime, timedelta
|
|
25
|
+
|
|
26
|
+
from wraithd import providers, voice
|
|
27
|
+
from wraithd.channels.conversation import reply
|
|
28
|
+
from wraithd.core import events as ev
|
|
29
|
+
from wraithd.core.gate import approval_card
|
|
30
|
+
from wraithd.store import base as store_base
|
|
31
|
+
from wraithd.tools import files
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
PAIRING_MINUTES = 10
|
|
36
|
+
PAIRING_TRIES = 5
|
|
37
|
+
IGNORED_LOG_EVERY = timedelta(hours=1) # per stranger chat
|
|
38
|
+
MAX_FILE_BYTES = 20_000_000
|
|
39
|
+
MAX_PHOTO_BYTES = 5_000_000 # what goes to the model as an image (Anthropic takes up to 5 MB)
|
|
40
|
+
INBOX = 'inbox' # in workspace/files/
|
|
41
|
+
ALLOW, DENY = 'a', 'd' # a choice's data is '<a|d>:<approval id>'
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ChannelError(Exception):
|
|
45
|
+
"""A call to the channel's service failed. The message never includes a token or a URL with one in it."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class FileTooLarge(ChannelError):
|
|
49
|
+
"""A download is over its cap."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Unusable(Exception):
|
|
53
|
+
"""An attachment that won't reach a run. The message is the reply the owner gets."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Attachment:
|
|
58
|
+
kind: str # 'photo', 'voice' or 'file'
|
|
59
|
+
ref: str # the channel's own id to fetch it with
|
|
60
|
+
size: int | None = None # what the service declares, before downloading
|
|
61
|
+
name: str = '' # a file's name, '' when the service gives none
|
|
62
|
+
mime: str = ''
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class Incoming:
|
|
67
|
+
"""One message, as the channel's service gave it."""
|
|
68
|
+
|
|
69
|
+
chat: object # the chat id, in the channel's own type
|
|
70
|
+
who: dict = field(default_factory=dict) # username / first_name, for the log and the owner's name
|
|
71
|
+
text: str = '' # the message text (commands live here)
|
|
72
|
+
caption: str = '' # an attachment's caption
|
|
73
|
+
attachment: Attachment | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class Choice:
|
|
78
|
+
label: str
|
|
79
|
+
data: str
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class Decision:
|
|
84
|
+
"""Someone picked a choice (a button tap, a numbered reply)."""
|
|
85
|
+
|
|
86
|
+
chat: object
|
|
87
|
+
data: str
|
|
88
|
+
ref: object = None # what acknowledge() answers (a callback query id)
|
|
89
|
+
who: dict = field(default_factory=dict)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def new_pairing(store, channel: str, now: datetime | None = None) -> dict:
|
|
93
|
+
"""A fresh pairing code for the channel, replacing any earlier one."""
|
|
94
|
+
now = now or datetime.now(UTC)
|
|
95
|
+
pairing = {'code': f'{secrets.randbelow(1_000_000):06d}',
|
|
96
|
+
'expires_at': (now + timedelta(minutes=PAIRING_MINUTES)).isoformat(timespec='microseconds'),
|
|
97
|
+
'tries': 0}
|
|
98
|
+
store.set_setting(f'{channel}.pairing', json.dumps(pairing))
|
|
99
|
+
return pairing
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def approval_prompt(data: dict) -> tuple[str, list[Choice]]:
|
|
103
|
+
"""The card for an approval_required callback: tool, arguments, why, third-party warnings; Allow and Deny."""
|
|
104
|
+
lines = [f'Allow {data["tool"]}?']
|
|
105
|
+
lines += [f' {key}: {value if isinstance(value, str) else json.dumps(value)}'
|
|
106
|
+
for key, value in data['arguments'].items()]
|
|
107
|
+
if data['reason']:
|
|
108
|
+
lines.append(data['reason'])
|
|
109
|
+
lines += [f'! {r["value"]} was not written by you (it came from {r["source"]})'
|
|
110
|
+
for r in data['suspicious_recipients']]
|
|
111
|
+
if data['untrusted_sources'] and not data['suspicious_recipients']:
|
|
112
|
+
lines.append(f'! This run read third-party content first: {", ".join(data["untrusted_sources"])}')
|
|
113
|
+
return '\n'.join(lines), [Choice('Allow', f'{ALLOW}:{data["approval_id"]}'),
|
|
114
|
+
Choice('Deny', f'{DENY}:{data["approval_id"]}')]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class Channel:
|
|
118
|
+
name = '' # settings prefix, conversation kind and run source
|
|
119
|
+
title = '' # how replies name the service
|
|
120
|
+
ignored_event = '' # the between-runs event for a stranger
|
|
121
|
+
max_file_bytes = MAX_FILE_BYTES
|
|
122
|
+
max_photo_bytes = MAX_PHOTO_BYTES
|
|
123
|
+
|
|
124
|
+
def __init__(self, rt, mcp=None, clock=lambda: datetime.now(UTC)):
|
|
125
|
+
self.rt, self.mcp, self.clock = rt, mcp, clock
|
|
126
|
+
self.tasks: set[asyncio.Task] = set()
|
|
127
|
+
self.cards: dict[str, object] = {} # approval id -> where its prompt is (what close_prompt needs)
|
|
128
|
+
# stranger chat -> when it was last logged. In memory: a daemon restart logs each one again, which is fine
|
|
129
|
+
self.ignored_logged: dict[object, datetime] = {}
|
|
130
|
+
|
|
131
|
+
# --- the transport: each channel ---------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
async def send(self, chat, text: str, choices: list[Choice] | None = None):
|
|
134
|
+
"""Sends text (and choices) to a chat; returns where the last part is, for close_prompt."""
|
|
135
|
+
raise NotImplementedError
|
|
136
|
+
|
|
137
|
+
async def fetch(self, attachment: Attachment, limit: int) -> bytes:
|
|
138
|
+
"""The attachment's bytes, at most limit (FileTooLarge past it, ChannelError when it fails)."""
|
|
139
|
+
raise NotImplementedError
|
|
140
|
+
|
|
141
|
+
async def acknowledge(self, decision: Decision, text: str) -> None:
|
|
142
|
+
"""Tells whoever picked a choice what came of it."""
|
|
143
|
+
raise NotImplementedError
|
|
144
|
+
|
|
145
|
+
async def close_prompt(self, where, status: str) -> None:
|
|
146
|
+
"""Replaces a decided prompt's choices with its outcome."""
|
|
147
|
+
raise NotImplementedError
|
|
148
|
+
|
|
149
|
+
def chat_from_setting(self, value: str):
|
|
150
|
+
return value
|
|
151
|
+
|
|
152
|
+
# --- the owner -----------------------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
def setting(self, key: str) -> str:
|
|
155
|
+
return f'{self.name}.{key}'
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def owner_chat(self):
|
|
159
|
+
value = self.rt.store.get_setting(self.setting('owner_chat'))
|
|
160
|
+
return self.chat_from_setting(value) if value else None
|
|
161
|
+
|
|
162
|
+
async def pair(self, chat, who: dict, code: str) -> None:
|
|
163
|
+
key = self.setting('pairing')
|
|
164
|
+
pairing = json.loads(self.rt.store.get_setting(key) or 'null')
|
|
165
|
+
if not pairing or datetime.fromisoformat(pairing['expires_at']) < self.clock():
|
|
166
|
+
self.rt.store.set_setting(key, None)
|
|
167
|
+
return await self.send(chat, 'There is no pairing code waiting. Make one with '
|
|
168
|
+
f'`wraithd {self.name} pair`.')
|
|
169
|
+
if not secrets.compare_digest(code, pairing['code']):
|
|
170
|
+
pairing['tries'] += 1
|
|
171
|
+
self.rt.store.set_setting(key, json.dumps(pairing) if pairing['tries'] < PAIRING_TRIES else None)
|
|
172
|
+
return await self.send(chat, 'That code is not right.')
|
|
173
|
+
self.rt.store.set_setting(key, None)
|
|
174
|
+
self.rt.store.set_setting(self.setting('owner_chat'), str(chat))
|
|
175
|
+
self.rt.store.set_setting(self.setting('owner_name'), who.get('username') and f'@{who["username"]}'
|
|
176
|
+
or who.get('first_name') or str(chat))
|
|
177
|
+
await self.send(chat, 'Paired: this chat is now the owner\'s.')
|
|
178
|
+
|
|
179
|
+
def ignore(self, chat, who: dict) -> None:
|
|
180
|
+
"""A stranger: nothing is sent back and no model sees it, so it costs nothing and leaks nothing.
|
|
181
|
+
Logged by chat id and username only: the text is a stranger's, and the log is the owner's."""
|
|
182
|
+
now, last = self.clock(), self.ignored_logged.get(chat)
|
|
183
|
+
if last is not None and now - last < IGNORED_LOG_EVERY:
|
|
184
|
+
return
|
|
185
|
+
self.ignored_logged[chat] = now
|
|
186
|
+
ev.record_event(self.rt.store, self.ignored_event, chat=chat, username=who.get('username') or '')
|
|
187
|
+
|
|
188
|
+
# --- receiving -----------------------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
async def receive(self, message: Incoming) -> None:
|
|
191
|
+
text, chat = message.text.strip(), message.chat
|
|
192
|
+
if text.startswith('/pair'):
|
|
193
|
+
return await self.pair(chat, message.who, text.removeprefix('/pair').strip())
|
|
194
|
+
if chat != self.owner_chat:
|
|
195
|
+
return self.ignore(chat, message.who)
|
|
196
|
+
if not text and message.attachment is None:
|
|
197
|
+
return
|
|
198
|
+
if text == '/start':
|
|
199
|
+
return await self.send(chat, 'Hi. This is a personal assistant. Its owner pairs it with '
|
|
200
|
+
f'`wraithd {self.name} pair`.')
|
|
201
|
+
task = asyncio.create_task(self.answer(message)) # so choices keep being handled while it runs
|
|
202
|
+
self.tasks.add(task)
|
|
203
|
+
task.add_done_callback(self.tasks.discard)
|
|
204
|
+
|
|
205
|
+
async def answer(self, message: Incoming) -> None:
|
|
206
|
+
"""The owner's message (receive() never gets here for anyone else)."""
|
|
207
|
+
chat = message.chat
|
|
208
|
+
try:
|
|
209
|
+
text, images = await self.for_run(message)
|
|
210
|
+
except Unusable as e:
|
|
211
|
+
return await self.send(chat, str(e))
|
|
212
|
+
conversation = (self.rt.store.find_conversation(self.name, str(chat))
|
|
213
|
+
or self.rt.store.start_conversation(self.name, external=str(chat)))
|
|
214
|
+
result = await reply(self.rt, conversation.id, text, source=self.name,
|
|
215
|
+
callback=self.approval_callback(chat), mcp=self.mcp, images=images)
|
|
216
|
+
await self.send(chat, result.answer)
|
|
217
|
+
|
|
218
|
+
async def settle(self) -> None:
|
|
219
|
+
"""Waits for the messages being answered (tests, and a clean shutdown)."""
|
|
220
|
+
while self.tasks:
|
|
221
|
+
await asyncio.gather(*list(self.tasks), return_exceptions=True)
|
|
222
|
+
|
|
223
|
+
# --- attachments ---------------------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
async def for_run(self, message: Incoming) -> tuple[str, list[str]]:
|
|
226
|
+
"""The run's message and images, attachments downloaded. Raises Unusable."""
|
|
227
|
+
text = (message.text or message.caption).strip()
|
|
228
|
+
item = message.attachment
|
|
229
|
+
if item is None:
|
|
230
|
+
return text, []
|
|
231
|
+
if item.kind == 'photo':
|
|
232
|
+
return await self._photo(item, text)
|
|
233
|
+
if item.kind == 'voice':
|
|
234
|
+
return await self._voice(item, text), []
|
|
235
|
+
return await self._file(item, text), []
|
|
236
|
+
|
|
237
|
+
async def _download(self, item: Attachment, limit: int) -> bytes:
|
|
238
|
+
too_large = f'That is too large for me to download from {self.title} ({limit // 1_000_000} MB at most).'
|
|
239
|
+
if (item.size or 0) > limit:
|
|
240
|
+
raise Unusable(too_large)
|
|
241
|
+
try:
|
|
242
|
+
return await self.fetch(item, limit)
|
|
243
|
+
except FileTooLarge:
|
|
244
|
+
raise Unusable(too_large) from None
|
|
245
|
+
except ChannelError as e:
|
|
246
|
+
logger.warning(str(e))
|
|
247
|
+
raise Unusable(f'I couldn\'t download that from {self.title}. Please send it again.') from None
|
|
248
|
+
|
|
249
|
+
def _save(self, name: str, data: bytes) -> str:
|
|
250
|
+
try:
|
|
251
|
+
name = f'{self.clock().astimezone():%Y-%m-%d}-{files.safe_name(name)}'
|
|
252
|
+
return files.save_new(self.rt.home.files_dir, INBOX, name, data)
|
|
253
|
+
except (files.OutsideWorkspace, OSError) as e:
|
|
254
|
+
logger.warning(f'Saving a {self.title} file failed: {e}')
|
|
255
|
+
raise Unusable('I couldn\'t save that file in your workspace.') from None
|
|
256
|
+
|
|
257
|
+
async def _photo(self, item: Attachment, caption: str) -> tuple[str, list[str]]:
|
|
258
|
+
"""Image input; saved instead when the model can't see. The channel picks the size (the largest that fits)."""
|
|
259
|
+
if (item.size or 0) > self.max_photo_bytes:
|
|
260
|
+
raise Unusable(f'That photo is too large for me ({self.max_photo_bytes // 1_000_000} MB at most).')
|
|
261
|
+
data = await self._download(item, self.max_photo_bytes)
|
|
262
|
+
if not providers.can_see_images(self.rt.config):
|
|
263
|
+
saved = self._save('photo.jpg', data)
|
|
264
|
+
raise Unusable(f'Your model ({self.rt.config.model.default}) can\'t see images, so I saved the photo to '
|
|
265
|
+
f'your workspace files as {saved}.')
|
|
266
|
+
image = f'data:{item.mime or "image/jpeg"};base64,' + base64.b64encode(data).decode()
|
|
267
|
+
return (f'[photo] {caption}' if caption else '[photo, no caption]'), [image]
|
|
268
|
+
|
|
269
|
+
async def _voice(self, item: Attachment, caption: str) -> str:
|
|
270
|
+
transcriber = voice.transcriber(self.rt.config, self.rt.voice_transport)
|
|
271
|
+
if transcriber is None:
|
|
272
|
+
why = voice.choose_transcriber(self.rt.config).why
|
|
273
|
+
raise Unusable(f'I can\'t listen to voice notes yet: transcription is {why}.')
|
|
274
|
+
data = await self._download(item, self.max_file_bytes)
|
|
275
|
+
try:
|
|
276
|
+
words = await transcriber.transcribe(data, 'voice.ogg', item.mime or 'audio/ogg')
|
|
277
|
+
except voice.TranscriptionFailed as e:
|
|
278
|
+
raise Unusable(f'I couldn\'t transcribe that voice note: {e}') from None
|
|
279
|
+
if not words:
|
|
280
|
+
raise Unusable('I couldn\'t hear any words in that voice note.')
|
|
281
|
+
return f'[voice note, transcribed] {words}' + (f'\n[caption] {caption}' if caption else '')
|
|
282
|
+
|
|
283
|
+
async def _file(self, item: Attachment, caption: str) -> str:
|
|
284
|
+
data = await self._download(item, self.max_file_bytes)
|
|
285
|
+
saved = self._save(item.name or 'file', data)
|
|
286
|
+
note = f'[The user sent a file, saved in the workspace files as {saved}. Read it with files_read if needed.]'
|
|
287
|
+
return f'{caption}\n{note}' if caption else note
|
|
288
|
+
|
|
289
|
+
# --- approvals -----------------------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
def approval_callback(self, chat):
|
|
292
|
+
async def callback(event_type: str, data: dict) -> None:
|
|
293
|
+
if event_type == 'approval_required':
|
|
294
|
+
text, choices = approval_prompt(data)
|
|
295
|
+
self.cards[data['approval_id']] = await self.send(chat, text, choices)
|
|
296
|
+
elif event_type == 'approval_resolved':
|
|
297
|
+
await self._close(data['approval_id'], data['status'])
|
|
298
|
+
return callback
|
|
299
|
+
|
|
300
|
+
async def decide(self, decision: Decision) -> None:
|
|
301
|
+
"""A choice picked: only the paired owner's chat decides; anyone else is refused (and logged as ignored)."""
|
|
302
|
+
action, _, approval_id = decision.data.partition(':')
|
|
303
|
+
owner = self.owner_chat
|
|
304
|
+
if decision.chat != owner or action not in (ALLOW, DENY):
|
|
305
|
+
if decision.chat != owner and decision.chat is not None:
|
|
306
|
+
self.ignore(decision.chat, decision.who)
|
|
307
|
+
return await self.acknowledge(decision, 'Only the owner can decide.')
|
|
308
|
+
decision_ = store_base.APPROVED if action == ALLOW else store_base.DENIED
|
|
309
|
+
decided = self.rt.store.decide_approval(approval_id, decision_)
|
|
310
|
+
approval = self.rt.store.get_approval(approval_id)
|
|
311
|
+
status = approval_card(approval)['status'] if approval else 'unknown'
|
|
312
|
+
await self.acknowledge(decision, status.capitalize() if decided else f'Already {status}.')
|
|
313
|
+
await self._close(approval_id, status)
|
|
314
|
+
|
|
315
|
+
async def _close(self, approval_id: str, status: str) -> None:
|
|
316
|
+
where = self.cards.pop(approval_id, None)
|
|
317
|
+
if where is not None:
|
|
318
|
+
await self.close_prompt(where, status)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
One message in a conversation, the same way for every channel: store it, compact the history, run, store the
|
|
3
|
+
answer. Conversations run one message at a time, so a second message waits for the first answer instead of
|
|
4
|
+
racing it (the web API refuses it instead of waiting, see daemon/api.py).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
|
|
9
|
+
from wraithd.core import compaction
|
|
10
|
+
from wraithd.run import RunResult, Runtime, run
|
|
11
|
+
|
|
12
|
+
_locks: dict[str, asyncio.Lock] = {}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def lock_for(conversation_id: str) -> asyncio.Lock:
|
|
16
|
+
return _locks.setdefault(conversation_id, asyncio.Lock())
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def reply(rt: Runtime, conversation_id: str, message: str, **run_options) -> RunResult:
|
|
20
|
+
"""Answers one message in the conversation. run_options go to run() (callback, source, owner, mcp...)."""
|
|
21
|
+
async with lock_for(conversation_id):
|
|
22
|
+
rt.store.add_message(conversation_id, 'user', message)
|
|
23
|
+
history = await compaction.history_for(rt.store, conversation_id, rt.completer('compaction'))
|
|
24
|
+
result = await run(rt, message, history=history, **run_options)
|
|
25
|
+
rt.store.add_message(conversation_id, 'assistant', result.answer, run_id=result.run_id)
|
|
26
|
+
return result
|