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.
Files changed (112) hide show
  1. wraithd/__init__.py +5 -0
  2. wraithd/__main__.py +5 -0
  3. wraithd/audit.py +251 -0
  4. wraithd/channels/__init__.py +1 -0
  5. wraithd/channels/base.py +318 -0
  6. wraithd/channels/conversation.py +26 -0
  7. wraithd/channels/telegram.py +238 -0
  8. wraithd/channels/terminal.py +111 -0
  9. wraithd/cli.py +319 -0
  10. wraithd/config.py +414 -0
  11. wraithd/core/__init__.py +13 -0
  12. wraithd/core/compaction.py +64 -0
  13. wraithd/core/delegate.py +116 -0
  14. wraithd/core/disclosure.py +261 -0
  15. wraithd/core/egress.py +115 -0
  16. wraithd/core/events.py +150 -0
  17. wraithd/core/gate.py +748 -0
  18. wraithd/core/hooks.py +59 -0
  19. wraithd/core/inspector.py +180 -0
  20. wraithd/core/loop.py +99 -0
  21. wraithd/core/reminders.py +279 -0
  22. wraithd/core/render.py +91 -0
  23. wraithd/core/replay.py +87 -0
  24. wraithd/core/secrets.py +87 -0
  25. wraithd/core/stop.py +90 -0
  26. wraithd/core/tokens.py +303 -0
  27. wraithd/core/tool_results.py +57 -0
  28. wraithd/core/views.py +93 -0
  29. wraithd/daemon/__init__.py +4 -0
  30. wraithd/daemon/api.py +537 -0
  31. wraithd/daemon/app.py +111 -0
  32. wraithd/daemon/commands.py +150 -0
  33. wraithd/daemon/heartbeat.py +213 -0
  34. wraithd/daemon/jobs.py +88 -0
  35. wraithd/daemon/scheduler.py +51 -0
  36. wraithd/daemon/security.py +112 -0
  37. wraithd/daemon/service.py +316 -0
  38. wraithd/doctor.py +184 -0
  39. wraithd/evals/__init__.py +0 -0
  40. wraithd/evals/__main__.py +75 -0
  41. wraithd/evals/meter.py +129 -0
  42. wraithd/evals/runner.py +451 -0
  43. wraithd/evals/scripted.py +210 -0
  44. wraithd/evals/site.py +50 -0
  45. wraithd/evals/tasks.py +302 -0
  46. wraithd/evals/world.py +207 -0
  47. wraithd/mcp/__init__.py +4 -0
  48. wraithd/mcp/client.py +194 -0
  49. wraithd/mcp/commands.py +262 -0
  50. wraithd/mcp/config.py +210 -0
  51. wraithd/mcp/pinning.py +245 -0
  52. wraithd/memory/__init__.py +6 -0
  53. wraithd/memory/distill.py +119 -0
  54. wraithd/memory/notes.py +135 -0
  55. wraithd/memory/review.py +98 -0
  56. wraithd/memory/tools.py +207 -0
  57. wraithd/memory/vault.py +233 -0
  58. wraithd/onboard.py +337 -0
  59. wraithd/providers.py +230 -0
  60. wraithd/rename.py +130 -0
  61. wraithd/review/__init__.py +1 -0
  62. wraithd/review/api.py +70 -0
  63. wraithd/review/cli.py +91 -0
  64. wraithd/review/queue.py +262 -0
  65. wraithd/run.py +392 -0
  66. wraithd/sandbox/__init__.py +90 -0
  67. wraithd/sandbox/audit.py +69 -0
  68. wraithd/sandbox/docker.py +87 -0
  69. wraithd/sandbox/settings.py +61 -0
  70. wraithd/schedules.py +112 -0
  71. wraithd/skills/__init__.py +4 -0
  72. wraithd/skills/library/inbox-triage/SKILL.md +24 -0
  73. wraithd/skills/library/meeting-prep/SKILL.md +20 -0
  74. wraithd/skills/library/morning-brief/SKILL.md +20 -0
  75. wraithd/skills/library/research-report/SKILL.md +16 -0
  76. wraithd/skills/library/research-report/report-template.md +16 -0
  77. wraithd/skills/library/skill-writer/SKILL.md +19 -0
  78. wraithd/skills/library/weekly-review/SKILL.md +18 -0
  79. wraithd/skills/registry.py +227 -0
  80. wraithd/skills/review.py +166 -0
  81. wraithd/skills/tools.py +138 -0
  82. wraithd/spill/__init__.py +91 -0
  83. wraithd/store/__init__.py +22 -0
  84. wraithd/store/base.py +184 -0
  85. wraithd/store/modules.py +115 -0
  86. wraithd/store/sqlite.py +383 -0
  87. wraithd/tools/__init__.py +95 -0
  88. wraithd/tools/base.py +46 -0
  89. wraithd/tools/browser.py +485 -0
  90. wraithd/tools/documents.py +214 -0
  91. wraithd/tools/files.py +350 -0
  92. wraithd/tools/notify.py +45 -0
  93. wraithd/tools/schedules.py +64 -0
  94. wraithd/tools/shell.py +97 -0
  95. wraithd/tools/web.py +426 -0
  96. wraithd/ui/assets/index-B9FtiVGr.css +1 -0
  97. wraithd/ui/assets/index-E7uVIjF7.js +15 -0
  98. wraithd/ui/assets/instrument-serif-latin-400-italic-DKMiL14s.woff2 +0 -0
  99. wraithd/ui/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  100. wraithd/ui/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  101. wraithd/ui/assets/inter-tight-latin-wght-normal-DX-nOvPD.woff2 +0 -0
  102. wraithd/ui/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  103. wraithd/ui/assets/server-DdTQ3pZ0.js +22 -0
  104. wraithd/ui/favicon.svg +1 -0
  105. wraithd/ui/index.html +17 -0
  106. wraithd/update.py +59 -0
  107. wraithd/voice.py +157 -0
  108. wraithd-0.0.1.dist-info/METADATA +161 -0
  109. wraithd-0.0.1.dist-info/RECORD +112 -0
  110. wraithd-0.0.1.dist-info/WHEEL +4 -0
  111. wraithd-0.0.1.dist-info/entry_points.txt +2 -0
  112. wraithd-0.0.1.dist-info/licenses/LICENSE +21 -0
wraithd/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """wraithd: a personal agent that runs on your own machine."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version('wraithd')
wraithd/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """`python -m wraithd`: the same as the `wraithd` command (the background service runs it this way)."""
2
+
3
+ from wraithd.cli import main
4
+
5
+ raise SystemExit(main())
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)."""
@@ -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