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
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Telegram, by long polling over httpx: no public URL, no webhook, no extra library.
|
|
3
|
+
|
|
4
|
+
- Pairing: `wraithd telegram pair` (or the web UI) makes a 6-digit code; the owner sends `/pair <code>` to the
|
|
5
|
+
bot within 10 minutes, and that chat becomes the owner's. Five wrong tries and the code is gone.
|
|
6
|
+
- The owner's messages are runs like the terminal's (memory, files, apps), and writes ask with inline buttons,
|
|
7
|
+
decided through the same approval store as everywhere else.
|
|
8
|
+
- The bot is personal: anyone else's message gets no run, no model call and no reply, except `/pair <code>` (the
|
|
9
|
+
owner's first contact comes from a chat that isn't paired yet). Each such chat is logged as a telegram_ignored
|
|
10
|
+
event, at most once an hour, so the owner can see someone tried without strangers filling the log.
|
|
11
|
+
- Attachments from the owner: a photo goes to the model as image input with its caption (saved to the workspace
|
|
12
|
+
instead when the model can't see images), a voice note is transcribed (voice.py) and the transcript is the
|
|
13
|
+
message, and any other file is saved to workspace/files/inbox/ with a line in the message saying where. A
|
|
14
|
+
stranger's attachment is never downloaded. Downloads are capped (Telegram lets bots download 20 MB at most).
|
|
15
|
+
- The bot token is in the keychain (`wraithd key set telegram`) and registered for redaction; errors never
|
|
16
|
+
include the API URL, since the token is in it.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import logging
|
|
21
|
+
from datetime import UTC, datetime
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
from wraithd import providers, voice # noqa: F401 (used by channels/base.py; tests patch them through here)
|
|
26
|
+
from wraithd.channels import base
|
|
27
|
+
from wraithd.core import events as ev
|
|
28
|
+
from wraithd.tools.notify import StoreNotifier
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
API = 'https://api.telegram.org'
|
|
33
|
+
POLL_SECONDS = 30
|
|
34
|
+
MESSAGE_CHARS = 4000 # Telegram's limit is 4096
|
|
35
|
+
MAX_FILE_BYTES = 20_000_000 # the Bot API's own limit for downloads
|
|
36
|
+
MAX_PHOTO_BYTES = base.MAX_PHOTO_BYTES
|
|
37
|
+
FILE_KINDS = {'document': 'file', 'audio': 'audio', 'video': 'video.mp4', 'video_note': 'video-note.mp4',
|
|
38
|
+
'animation': 'animation.mp4'} # saved to the inbox; the value is the name when Telegram gives none
|
|
39
|
+
OWNER_CHAT, OWNER_NAME, PAIRING, OFFSET = 'telegram.owner_chat', 'telegram.owner_name', 'telegram.pairing', \
|
|
40
|
+
'telegram.offset'
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TelegramError(base.ChannelError):
|
|
44
|
+
"""A Bot API call failed. The message never includes the URL (it has the token in it)."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class FileTooLarge(TelegramError, base.FileTooLarge):
|
|
48
|
+
"""A download is over its cap."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def new_pairing(store, now: datetime | None = None) -> dict:
|
|
52
|
+
"""A fresh pairing code, replacing any earlier one."""
|
|
53
|
+
return base.new_pairing(store, 'telegram', now)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _chunks(text: str) -> list[str]:
|
|
57
|
+
text = text or '(no answer)'
|
|
58
|
+
return [text[i:i + MESSAGE_CHARS] for i in range(0, len(text), MESSAGE_CHARS)]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TelegramBot:
|
|
62
|
+
"""The Bot API calls the channel needs."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, token: str, transport: httpx.AsyncBaseTransport | None = None):
|
|
65
|
+
self._client = httpx.AsyncClient(base_url=f'{API}/bot{token}/', transport=transport,
|
|
66
|
+
timeout=POLL_SECONDS + 10)
|
|
67
|
+
self._files = f'{API}/file/bot{token}/' # has the token in it too: never logged or shown
|
|
68
|
+
|
|
69
|
+
async def call(self, method: str, **params) -> dict | list:
|
|
70
|
+
try:
|
|
71
|
+
response = await self._client.post(method, json=params)
|
|
72
|
+
data = response.json()
|
|
73
|
+
except (httpx.HTTPError, ValueError) as e:
|
|
74
|
+
raise TelegramError(f'Telegram {method} failed ({type(e).__name__}).') from None
|
|
75
|
+
if not data.get('ok'):
|
|
76
|
+
raise TelegramError(f'Telegram {method} failed: {data.get("description", "unknown error")}')
|
|
77
|
+
return data['result']
|
|
78
|
+
|
|
79
|
+
async def download(self, file_id: str, limit: int) -> bytes:
|
|
80
|
+
"""A file someone sent, through getFile, read up to limit bytes (FileTooLarge past it)."""
|
|
81
|
+
info = await self.call('getFile', file_id=file_id)
|
|
82
|
+
if (info.get('file_size') or 0) > limit:
|
|
83
|
+
raise FileTooLarge('The file is too large.')
|
|
84
|
+
if not info.get('file_path'):
|
|
85
|
+
raise TelegramError('Telegram getFile gave no file path.')
|
|
86
|
+
data = bytearray()
|
|
87
|
+
try:
|
|
88
|
+
async with self._client.stream('GET', self._files + info['file_path']) as response:
|
|
89
|
+
response.raise_for_status()
|
|
90
|
+
async for chunk in response.aiter_bytes():
|
|
91
|
+
data += chunk
|
|
92
|
+
if len(data) > limit:
|
|
93
|
+
raise FileTooLarge('The file is too large.')
|
|
94
|
+
except httpx.HTTPError as e:
|
|
95
|
+
raise TelegramError(f'Telegram file download failed ({type(e).__name__}).') from None
|
|
96
|
+
return bytes(data)
|
|
97
|
+
|
|
98
|
+
async def send(self, chat_id, text: str, buttons=None) -> int | None:
|
|
99
|
+
message_id = None
|
|
100
|
+
for part in _chunks(text):
|
|
101
|
+
params = {'chat_id': chat_id, 'text': part}
|
|
102
|
+
if buttons:
|
|
103
|
+
params['reply_markup'] = {'inline_keyboard': buttons}
|
|
104
|
+
message_id = (await self.call('sendMessage', **params))['message_id']
|
|
105
|
+
return message_id
|
|
106
|
+
|
|
107
|
+
async def close(self) -> None:
|
|
108
|
+
await self._client.aclose()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class TelegramChannel(base.Channel):
|
|
112
|
+
"""The channel interface (channels/base.py) over the Bot API: updates in, messages and inline buttons out."""
|
|
113
|
+
|
|
114
|
+
name, title, ignored_event = 'telegram', 'Telegram', ev.TELEGRAM_IGNORED
|
|
115
|
+
# read when used, so the module's limits can be changed (tests do)
|
|
116
|
+
max_file_bytes = property(lambda self: MAX_FILE_BYTES)
|
|
117
|
+
max_photo_bytes = property(lambda self: MAX_PHOTO_BYTES)
|
|
118
|
+
|
|
119
|
+
def __init__(self, rt, bot: TelegramBot, mcp=None, clock=lambda: datetime.now(UTC)):
|
|
120
|
+
super().__init__(rt, mcp=mcp, clock=clock)
|
|
121
|
+
self.bot = bot
|
|
122
|
+
|
|
123
|
+
def chat_from_setting(self, value: str) -> int:
|
|
124
|
+
return int(value)
|
|
125
|
+
|
|
126
|
+
# --- the transport -------------------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
async def send(self, chat, text: str, choices: list[base.Choice] | None = None) -> tuple[int, int | None]:
|
|
129
|
+
buttons = [[{'text': c.label, 'callback_data': c.data} for c in choices]] if choices else None
|
|
130
|
+
return chat, await self.bot.send(chat, text, buttons)
|
|
131
|
+
|
|
132
|
+
async def fetch(self, attachment: base.Attachment, limit: int) -> bytes:
|
|
133
|
+
return await self.bot.download(attachment.ref, limit)
|
|
134
|
+
|
|
135
|
+
async def acknowledge(self, decision: base.Decision, text: str) -> None:
|
|
136
|
+
await self.bot.call('answerCallbackQuery', callback_query_id=decision.ref, text=text)
|
|
137
|
+
|
|
138
|
+
async def close_prompt(self, where: tuple[int, int | None], status: str) -> None:
|
|
139
|
+
await self.bot.call('editMessageReplyMarkup', chat_id=where[0], message_id=where[1],
|
|
140
|
+
reply_markup={'inline_keyboard': [[{'text': status.capitalize(),
|
|
141
|
+
'callback_data': 'done'}]]})
|
|
142
|
+
|
|
143
|
+
# --- polling -------------------------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
async def poll_once(self, timeout: int = POLL_SECONDS) -> int:
|
|
146
|
+
"""One getUpdates call; each update is handled (messages in their own task). Returns how many arrived."""
|
|
147
|
+
offset = int(self.rt.store.get_setting(OFFSET) or 0)
|
|
148
|
+
updates = await self.bot.call('getUpdates', offset=offset, timeout=timeout,
|
|
149
|
+
allowed_updates=['message', 'callback_query'])
|
|
150
|
+
for update in updates:
|
|
151
|
+
# Move past it first: an update that crashes the handler must not come back forever
|
|
152
|
+
self.rt.store.set_setting(OFFSET, str(update['update_id'] + 1))
|
|
153
|
+
try:
|
|
154
|
+
await self.handle(update)
|
|
155
|
+
except Exception:
|
|
156
|
+
logger.exception('Telegram update failed')
|
|
157
|
+
return len(updates)
|
|
158
|
+
|
|
159
|
+
async def poll_for_pairing(self, timeout: int = 5) -> bool:
|
|
160
|
+
"""
|
|
161
|
+
For `wraithd onboard` when no daemon is polling: one getUpdates call that handles only `/pair` and stops
|
|
162
|
+
at the first pairing that works. Updates after it are left for the daemon, so the owner's first message
|
|
163
|
+
isn't lost; other messages before it (from chats that aren't the owner's yet) are passed over.
|
|
164
|
+
"""
|
|
165
|
+
offset = int(self.rt.store.get_setting(OFFSET) or 0)
|
|
166
|
+
updates = await self.bot.call('getUpdates', offset=offset, timeout=timeout, allowed_updates=['message'])
|
|
167
|
+
for update in updates:
|
|
168
|
+
self.rt.store.set_setting(OFFSET, str(update['update_id'] + 1))
|
|
169
|
+
message = update.get('message') or {}
|
|
170
|
+
text, chat = (message.get('text') or '').strip(), message.get('chat') or {}
|
|
171
|
+
if 'id' in chat and text.startswith('/pair'):
|
|
172
|
+
await self.pair(chat['id'], chat, text.removeprefix('/pair').strip())
|
|
173
|
+
if self.owner_chat is not None:
|
|
174
|
+
return True
|
|
175
|
+
return self.owner_chat is not None
|
|
176
|
+
|
|
177
|
+
async def run_forever(self) -> None:
|
|
178
|
+
while True:
|
|
179
|
+
try:
|
|
180
|
+
await self.poll_once()
|
|
181
|
+
except TelegramError as e:
|
|
182
|
+
logger.warning(str(e))
|
|
183
|
+
await asyncio.sleep(5)
|
|
184
|
+
|
|
185
|
+
# --- updates -------------------------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
async def handle(self, update: dict) -> None:
|
|
188
|
+
if 'callback_query' in update:
|
|
189
|
+
query = update['callback_query']
|
|
190
|
+
chat = ((query.get('message') or {}).get('chat') or {})
|
|
191
|
+
return await self.decide(base.Decision(chat=chat.get('id'), data=query.get('data') or '',
|
|
192
|
+
ref=query['id'], who=query.get('from') or chat))
|
|
193
|
+
message = update.get('message') or {}
|
|
194
|
+
chat = message.get('chat') or {}
|
|
195
|
+
if 'id' not in chat:
|
|
196
|
+
return
|
|
197
|
+
await self.receive(base.Incoming(chat=chat['id'], who=chat, text=message.get('text') or '',
|
|
198
|
+
caption=message.get('caption') or '', attachment=_attachment(message)))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _attachment(message: dict) -> base.Attachment | None:
|
|
202
|
+
"""The message's photo (the largest size that fits the cap), voice note or file, as the channel sees it."""
|
|
203
|
+
if message.get('photo'):
|
|
204
|
+
sizes = message['photo']
|
|
205
|
+
fitting = [size for size in sizes if (size.get('file_size') or 0) <= MAX_PHOTO_BYTES]
|
|
206
|
+
best = max(fitting, key=lambda size: (size.get('width', 0) * size.get('height', 0),
|
|
207
|
+
size.get('file_size', 0))) if fitting else \
|
|
208
|
+
min(sizes, key=lambda size: size.get('file_size') or 0) # none fits: too large, said without a download
|
|
209
|
+
return base.Attachment('photo', best['file_id'], best.get('file_size'), mime='image/jpeg') # always JPEG
|
|
210
|
+
if message.get('voice'):
|
|
211
|
+
note = message['voice']
|
|
212
|
+
return base.Attachment('voice', note['file_id'], note.get('file_size'), mime=note.get('mime_type') or '')
|
|
213
|
+
kind = next((kind for kind in FILE_KINDS if message.get(kind)), None)
|
|
214
|
+
if kind:
|
|
215
|
+
item = message[kind]
|
|
216
|
+
return base.Attachment('file', item['file_id'], item.get('file_size'),
|
|
217
|
+
name=item.get('file_name') or FILE_KINDS[kind], mime=item.get('mime_type') or '')
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class TelegramNotifier:
|
|
222
|
+
"""notify_owner through Telegram when there's a paired owner chat; otherwise into the store's inbox."""
|
|
223
|
+
|
|
224
|
+
channel = 'telegram'
|
|
225
|
+
|
|
226
|
+
def __init__(self, store, bot: TelegramBot):
|
|
227
|
+
self.store, self.bot, self.inbox = store, bot, StoreNotifier(store)
|
|
228
|
+
|
|
229
|
+
async def deliver(self, text: str) -> str:
|
|
230
|
+
owner = self.store.get_setting(OWNER_CHAT)
|
|
231
|
+
if not owner:
|
|
232
|
+
return await self.inbox.deliver(text)
|
|
233
|
+
try:
|
|
234
|
+
await self.bot.send(int(owner), text)
|
|
235
|
+
return self.channel
|
|
236
|
+
except TelegramError as e:
|
|
237
|
+
logger.warning(f'{e}; kept in the inbox instead')
|
|
238
|
+
return await self.inbox.deliver(text)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Chat in the terminal: the owner types, the answer streams back, and write actions ask y/N first.
|
|
3
|
+
|
|
4
|
+
One session is one conversation. Its messages are stored, so long sessions are compacted, and when the session
|
|
5
|
+
ends, distill turns it into memory: that's how "I prefer short replies" holds the next time.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import getpass
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from wraithd.core import compaction
|
|
14
|
+
from wraithd.mcp.client import McpConnections
|
|
15
|
+
from wraithd.memory import distill
|
|
16
|
+
from wraithd.run import Runtime, run
|
|
17
|
+
from wraithd.store import base as store_base
|
|
18
|
+
|
|
19
|
+
QUIT = {'/quit', '/exit', 'quit', 'exit', ':q'}
|
|
20
|
+
DISTILL_MIN_MESSAGES = 2 # at the end of a session, one exchange is enough to learn from
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Terminal:
|
|
24
|
+
"""Reading and writing, so tests can play the owner."""
|
|
25
|
+
|
|
26
|
+
async def ask(self, prompt: str) -> str | None:
|
|
27
|
+
"""A line from the owner, or None when input ends (Ctrl-D)."""
|
|
28
|
+
try:
|
|
29
|
+
return await asyncio.to_thread(input, prompt)
|
|
30
|
+
except EOFError:
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
async def secret(self, prompt: str) -> str | None:
|
|
34
|
+
"""A line typed without echo (keys, tokens), or None when input ends."""
|
|
35
|
+
try:
|
|
36
|
+
return await asyncio.to_thread(getpass.getpass, prompt)
|
|
37
|
+
except EOFError:
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
def say(self, text: str = '', end: str = '\n') -> None:
|
|
41
|
+
sys.stdout.write(text + end)
|
|
42
|
+
sys.stdout.flush()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _approval_card(data: dict) -> str:
|
|
46
|
+
lines = [f'\nThe agent wants to run {data["tool"]}:']
|
|
47
|
+
lines += [f' {key}: {json.dumps(value) if not isinstance(value, str) else value}'
|
|
48
|
+
for key, value in data['arguments'].items()]
|
|
49
|
+
if data.get('reason'):
|
|
50
|
+
lines.append(f' {data["reason"]}')
|
|
51
|
+
for recipient in data.get('suspicious_recipients') or []:
|
|
52
|
+
lines.append(f' ! {recipient["value"]} was not written by you (it came from {recipient["source"]})')
|
|
53
|
+
if data.get('untrusted_sources') and not data.get('suspicious_recipients'):
|
|
54
|
+
lines.append(f' ! This run read third-party content first: {", ".join(data["untrusted_sources"])}')
|
|
55
|
+
return '\n'.join(lines)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def make_callback(rt: Runtime, terminal: Terminal, state: dict):
|
|
59
|
+
"""Streams text as it's written, shows tool calls, and turns approval cards into a y/N question."""
|
|
60
|
+
|
|
61
|
+
async def callback(event_type: str, data: dict) -> None:
|
|
62
|
+
if event_type == 'content_delta':
|
|
63
|
+
state['streamed'] = True
|
|
64
|
+
terminal.say(data['content'], end='')
|
|
65
|
+
elif event_type == 'tool_call_start':
|
|
66
|
+
terminal.say(f' · {data["tool"]}')
|
|
67
|
+
elif event_type == 'approval_required':
|
|
68
|
+
terminal.say(_approval_card(data))
|
|
69
|
+
answer = (await terminal.ask('Allow? [y/N] ') or '').strip().lower()
|
|
70
|
+
decision = store_base.APPROVED if answer in ('y', 'yes') else store_base.DENIED
|
|
71
|
+
rt.store.decide_approval(data['approval_id'], decision)
|
|
72
|
+
|
|
73
|
+
return callback
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def chat(rt: Runtime, terminal: Terminal | None = None) -> str:
|
|
77
|
+
"""One session. Returns the conversation id."""
|
|
78
|
+
terminal = terminal or Terminal()
|
|
79
|
+
async with McpConnections(rt.config.mcp_servers, store=rt.store) as mcp: # once per session, not once per message
|
|
80
|
+
for name, problem in mcp.errors.items():
|
|
81
|
+
terminal.say(f'(MCP server {name} is not available this session: {problem})')
|
|
82
|
+
return await _session(rt, terminal, mcp)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def _session(rt: Runtime, terminal: Terminal, mcp) -> str:
|
|
86
|
+
conversation = rt.store.start_conversation('terminal')
|
|
87
|
+
terminal.say('wraithd. Type /quit to end; what you share is remembered for next time.')
|
|
88
|
+
try:
|
|
89
|
+
while True:
|
|
90
|
+
message = await terminal.ask('\n> ')
|
|
91
|
+
if message is None or message.strip().lower() in QUIT:
|
|
92
|
+
break
|
|
93
|
+
if not message.strip():
|
|
94
|
+
continue
|
|
95
|
+
rt.store.add_message(conversation.id, 'user', message)
|
|
96
|
+
history = await compaction.history_for(rt.store, conversation.id, rt.completer('compaction'))
|
|
97
|
+
state = {'streamed': False}
|
|
98
|
+
result = await run(rt, message, history=history, callback=make_callback(rt, terminal, state),
|
|
99
|
+
source='chat', mcp=mcp)
|
|
100
|
+
# A streamed answer is already on screen; a refusal or failure message never streamed
|
|
101
|
+
terminal.say('' if state['streamed'] else result.answer)
|
|
102
|
+
rt.store.add_message(conversation.id, 'assistant', result.answer, run_id=result.run_id)
|
|
103
|
+
finally:
|
|
104
|
+
try:
|
|
105
|
+
outcome = await distill.distill_conversation(rt.store, rt.memory, rt.completer('distill'),
|
|
106
|
+
conversation.id, min_messages=DISTILL_MIN_MESSAGES)
|
|
107
|
+
if outcome.get('notes'):
|
|
108
|
+
terminal.say(f'Remembered: {", ".join(outcome["notes"])}')
|
|
109
|
+
except Exception as e:
|
|
110
|
+
terminal.say(f'(Could not save what I learned this time: {type(e).__name__})')
|
|
111
|
+
return conversation.id
|
wraithd/cli.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The `wraithd` command.
|
|
3
|
+
|
|
4
|
+
wraithd onboard set everything up, one question at a time (safe to run again)
|
|
5
|
+
wraithd chat talk to the agent in the terminal
|
|
6
|
+
wraithd runs list the latest runs
|
|
7
|
+
wraithd runs show <id> [--json] one run's timeline (a unique prefix of the id is enough)
|
|
8
|
+
wraithd events [--type T] what happened between runs: compaction, distill, ignored Telegram chats
|
|
9
|
+
wraithd schedules list [--all] schedules and reminders, with their next times
|
|
10
|
+
wraithd schedules rm <id> cancel one
|
|
11
|
+
wraithd mcp add|list|tools|allow|disallow|remove your apps, as MCP servers (see mcp/commands.py)
|
|
12
|
+
wraithd daemon the API, web UI, scheduler and Telegram, in the foreground
|
|
13
|
+
wraithd daemon install|start|stop|restart|status|logs|uninstall the same, as a background service
|
|
14
|
+
wraithd doctor check the setup; a fix for anything wrong
|
|
15
|
+
wraithd audit check the live setup against the security rules
|
|
16
|
+
wraithd update upgrade (with uv or pipx) and restart the service
|
|
17
|
+
wraithd telegram pair a code that makes your Telegram chat the owner's
|
|
18
|
+
wraithd key set <provider> store an API key in the OS keychain
|
|
19
|
+
wraithd key rm <provider> delete it from the keychain
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import asyncio
|
|
24
|
+
import getpass
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import sys
|
|
28
|
+
|
|
29
|
+
from wraithd import __version__
|
|
30
|
+
from wraithd.config import KEY_NAMES, ConfigError, Home, NoKeychain, delete_key, set_key
|
|
31
|
+
from wraithd.core import events as ev
|
|
32
|
+
from wraithd.core import inspector
|
|
33
|
+
from wraithd.daemon import commands as daemon_commands
|
|
34
|
+
from wraithd.mcp import commands as mcp_commands
|
|
35
|
+
from wraithd.providers import ProviderError
|
|
36
|
+
from wraithd.store import SQLiteStore
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
40
|
+
parser = argparse.ArgumentParser(
|
|
41
|
+
prog='wraithd',
|
|
42
|
+
description='A personal agent that runs on your own machine.',
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument('--version', action='version', version=f'wraithd {__version__}')
|
|
45
|
+
commands = parser.add_subparsers(dest='command')
|
|
46
|
+
|
|
47
|
+
commands.add_parser('onboard', help='set up the model, keys, Telegram and the background service')
|
|
48
|
+
commands.add_parser('chat', help='talk to the agent in the terminal')
|
|
49
|
+
|
|
50
|
+
runs = commands.add_parser('runs', help='look at what runs did').add_subparsers(dest='runs_command',
|
|
51
|
+
required=True)
|
|
52
|
+
listing = runs.add_parser('list', help='the latest runs')
|
|
53
|
+
listing.add_argument('--limit', type=int, default=20)
|
|
54
|
+
show = runs.add_parser('show', help="one run's timeline")
|
|
55
|
+
show.add_argument('run_id', help='the run id, or a unique prefix of it')
|
|
56
|
+
show.add_argument('--json', action='store_true', help='the summary and every event, as JSON')
|
|
57
|
+
|
|
58
|
+
events = commands.add_parser('events', help='what happened between runs: compaction, distill, ignored chats')
|
|
59
|
+
events.add_argument('--limit', type=int, default=20)
|
|
60
|
+
events.add_argument('--type', choices=ev.BETWEEN_RUNS, action='append', help='only this type (repeatable)')
|
|
61
|
+
|
|
62
|
+
schedules = commands.add_parser('schedules', help='schedules and reminders').add_subparsers(
|
|
63
|
+
dest='schedules_command', required=True)
|
|
64
|
+
schedules_list = schedules.add_parser('list', help='active schedules (--all: cancelled and finished too)')
|
|
65
|
+
schedules_list.add_argument('--all', action='store_true')
|
|
66
|
+
schedules_rm = schedules.add_parser('rm', help='cancel a schedule')
|
|
67
|
+
schedules_rm.add_argument('schedule_id', type=int)
|
|
68
|
+
|
|
69
|
+
mcp_commands.add_arguments(commands)
|
|
70
|
+
daemon_commands.add_arguments(commands)
|
|
71
|
+
commands.add_parser('doctor', help='check the setup: settings, keys, permissions, the service, the daemon')
|
|
72
|
+
commands.add_parser('audit', help='check the setup against the security rules: the API, the token, the shell, '
|
|
73
|
+
'file permissions')
|
|
74
|
+
commands.add_parser('update', help='upgrade wraithd (uv or pipx) and restart the background service')
|
|
75
|
+
telegram = commands.add_parser('telegram', help='the Telegram channel').add_subparsers(
|
|
76
|
+
dest='telegram_command', required=True)
|
|
77
|
+
telegram.add_parser('pair', help='a code to send to your bot as /pair <code> (valid for 10 minutes)')
|
|
78
|
+
|
|
79
|
+
key = commands.add_parser('key', help='API keys, kept in the OS keychain').add_subparsers(dest='key_command',
|
|
80
|
+
required=True)
|
|
81
|
+
key_set = key.add_parser('set', help='store a key (asked for without echo)')
|
|
82
|
+
key_set.add_argument('provider', choices=KEY_NAMES)
|
|
83
|
+
key_rm = key.add_parser('rm', help='delete a key from the keychain')
|
|
84
|
+
key_rm.add_argument('provider', choices=KEY_NAMES)
|
|
85
|
+
# New commands: each worker adds its subparser to `commands` inside its own block only
|
|
86
|
+
# >>> security
|
|
87
|
+
from wraithd.review import cli as review_cli
|
|
88
|
+
|
|
89
|
+
review_cli.add_arguments(commands)
|
|
90
|
+
# <<< security
|
|
91
|
+
# >>> efficiency
|
|
92
|
+
tokens = runs.add_parser('tokens', help="where one run's input tokens went, part by part")
|
|
93
|
+
tokens.add_argument('run_id', help='the run id, or a unique prefix of it')
|
|
94
|
+
tokens.add_argument('--json', action='store_true')
|
|
95
|
+
tokens.set_defaults(command='runs_tokens') # handled in main's efficiency block
|
|
96
|
+
# <<< efficiency
|
|
97
|
+
# >>> capabilities
|
|
98
|
+
from wraithd.daemon import heartbeat
|
|
99
|
+
|
|
100
|
+
heartbeat.add_arguments(commands)
|
|
101
|
+
# <<< capabilities
|
|
102
|
+
# >>> reach
|
|
103
|
+
# <<< reach
|
|
104
|
+
# >>> evals
|
|
105
|
+
# <<< evals
|
|
106
|
+
# >>> sandbox
|
|
107
|
+
# <<< sandbox
|
|
108
|
+
return parser
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _store(home: Home) -> SQLiteStore | None:
|
|
112
|
+
return SQLiteStore(home.db_path) if home.db_path.exists() else None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def runs_list(home: Home, limit: int) -> int:
|
|
116
|
+
store = _store(home)
|
|
117
|
+
runs = store.recent_runs(limit) if store else []
|
|
118
|
+
if not runs:
|
|
119
|
+
print('No runs yet.')
|
|
120
|
+
return 0
|
|
121
|
+
for info in runs:
|
|
122
|
+
message = info.message if len(info.message) <= 60 else info.message[:57] + '...'
|
|
123
|
+
print(f'{info.run_id[:8]} {info.started_at.astimezone():%Y-%m-%d %H:%M} {info.source:<9} {message}')
|
|
124
|
+
return 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def runs_show(home: Home, prefix: str, as_json: bool) -> int:
|
|
128
|
+
store = _store(home)
|
|
129
|
+
matches = store.find_runs(prefix) if store and prefix else []
|
|
130
|
+
if len(matches) != 1:
|
|
131
|
+
print(f'No run matches {prefix!r}.' if not matches else
|
|
132
|
+
f'{len(matches)} runs start with {prefix!r}; give more of the id.', file=sys.stderr)
|
|
133
|
+
return 1
|
|
134
|
+
events = store.run_events(matches[0])
|
|
135
|
+
if as_json:
|
|
136
|
+
print(json.dumps({'run_id': matches[0], 'summary': inspector.summarize(events),
|
|
137
|
+
'events': inspector.timeline(events)}, indent=1, default=str))
|
|
138
|
+
else:
|
|
139
|
+
print(inspector.render_text(matches[0], events, children=store.run_events)) # subagents nested
|
|
140
|
+
return 0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def events_list(home: Home, limit: int, types) -> int:
|
|
144
|
+
store = _store(home)
|
|
145
|
+
listed = store.events_between_runs(limit, types or ()) if store else []
|
|
146
|
+
if not listed:
|
|
147
|
+
print('Nothing has happened between runs yet.')
|
|
148
|
+
return 0
|
|
149
|
+
for event in listed:
|
|
150
|
+
print(f'{event.created_at.astimezone():%Y-%m-%d %H:%M} {event.type:<16} {inspector.between_runs_text(event)}')
|
|
151
|
+
return 0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def schedules_list(home: Home, show_all: bool) -> int:
|
|
155
|
+
from wraithd.schedules import describe
|
|
156
|
+
|
|
157
|
+
store = _store(home)
|
|
158
|
+
listed = store.list_schedules(active_only=not show_all) if store else []
|
|
159
|
+
if not listed:
|
|
160
|
+
print('No schedules.')
|
|
161
|
+
return 0
|
|
162
|
+
for schedule in listed:
|
|
163
|
+
state = '' if schedule.active else ' [off]'
|
|
164
|
+
print(f'{schedule.id:>4} {describe(schedule)}{state}\n {schedule.message}')
|
|
165
|
+
return 0
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def schedules_rm(home: Home, schedule_id: int) -> int:
|
|
169
|
+
store = _store(home)
|
|
170
|
+
if not store or not store.cancel_schedule(schedule_id):
|
|
171
|
+
print(f'No active schedule {schedule_id}.', file=sys.stderr)
|
|
172
|
+
return 1
|
|
173
|
+
print(f'Cancelled schedule {schedule_id}.')
|
|
174
|
+
return 0
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def daemon(home: Home) -> int:
|
|
178
|
+
from wraithd.daemon.app import Daemon
|
|
179
|
+
from wraithd.run import Runtime
|
|
180
|
+
|
|
181
|
+
# Timestamps for the service's log file. WARNING and up only: httpx logs every request URL at INFO, and the
|
|
182
|
+
# Telegram ones have the bot token in them
|
|
183
|
+
logging.basicConfig(level=logging.WARNING, format='%(asctime)s %(levelname)s %(name)s: %(message)s')
|
|
184
|
+
try:
|
|
185
|
+
rt = Runtime.open(home)
|
|
186
|
+
except (ConfigError, ProviderError) as e:
|
|
187
|
+
print(e, file=sys.stderr)
|
|
188
|
+
return 1
|
|
189
|
+
try:
|
|
190
|
+
asyncio.run(Daemon(rt).serve())
|
|
191
|
+
except KeyboardInterrupt:
|
|
192
|
+
pass
|
|
193
|
+
finally:
|
|
194
|
+
rt.close()
|
|
195
|
+
return 0
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def telegram_pair(home: Home) -> int:
|
|
199
|
+
from wraithd.channels.telegram import new_pairing
|
|
200
|
+
|
|
201
|
+
home.ensure()
|
|
202
|
+
store = SQLiteStore(home.db_path)
|
|
203
|
+
try:
|
|
204
|
+
pairing = new_pairing(store)
|
|
205
|
+
finally:
|
|
206
|
+
store.close()
|
|
207
|
+
print(f'Send this to your bot within 10 minutes: /pair {pairing["code"]}')
|
|
208
|
+
return 0
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def key_set(provider: str) -> int:
|
|
212
|
+
value = getpass.getpass(f'{provider} API key (not shown): ').strip()
|
|
213
|
+
if not value:
|
|
214
|
+
print('Nothing saved.', file=sys.stderr)
|
|
215
|
+
return 1
|
|
216
|
+
try:
|
|
217
|
+
set_key(provider, value)
|
|
218
|
+
except NoKeychain as e:
|
|
219
|
+
print(e, file=sys.stderr)
|
|
220
|
+
return 1
|
|
221
|
+
print(f'Saved the {provider} key in the keychain.')
|
|
222
|
+
return 0
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def key_rm(provider: str) -> int:
|
|
226
|
+
if not delete_key(provider):
|
|
227
|
+
print(f'No {provider} key in the keychain.')
|
|
228
|
+
return 0
|
|
229
|
+
print(f'Deleted the {provider} key from the keychain.')
|
|
230
|
+
return 0
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def chat(home: Home) -> int:
|
|
234
|
+
from wraithd.channels.terminal import chat as chat_session
|
|
235
|
+
from wraithd.run import Runtime
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
rt = Runtime.open(home)
|
|
239
|
+
except (ConfigError, ProviderError) as e:
|
|
240
|
+
print(e, file=sys.stderr)
|
|
241
|
+
return 1
|
|
242
|
+
try:
|
|
243
|
+
asyncio.run(chat_session(rt))
|
|
244
|
+
except KeyboardInterrupt:
|
|
245
|
+
pass
|
|
246
|
+
finally:
|
|
247
|
+
rt.close()
|
|
248
|
+
return 0
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def main(argv: list[str] | None = None) -> int:
|
|
252
|
+
parser = build_parser()
|
|
253
|
+
argv, server_command = mcp_commands.split_command(sys.argv[1:] if argv is None else list(argv))
|
|
254
|
+
args = parser.parse_args(argv)
|
|
255
|
+
if server_command:
|
|
256
|
+
args.server_command = server_command
|
|
257
|
+
home = Home.locate()
|
|
258
|
+
if args.command == 'onboard':
|
|
259
|
+
from wraithd import onboard
|
|
260
|
+
return onboard.main(home)
|
|
261
|
+
if args.command == 'doctor':
|
|
262
|
+
from wraithd import doctor
|
|
263
|
+
return doctor.main(home)
|
|
264
|
+
if args.command == 'audit':
|
|
265
|
+
from wraithd import audit
|
|
266
|
+
return audit.main(home)
|
|
267
|
+
if args.command == 'update':
|
|
268
|
+
from wraithd import update
|
|
269
|
+
return update.update(home)
|
|
270
|
+
if args.command == 'chat':
|
|
271
|
+
return chat(home)
|
|
272
|
+
if args.command == 'runs':
|
|
273
|
+
if args.runs_command == 'list':
|
|
274
|
+
return runs_list(home, args.limit)
|
|
275
|
+
return runs_show(home, args.run_id, args.json)
|
|
276
|
+
if args.command == 'events':
|
|
277
|
+
return events_list(home, args.limit, args.type)
|
|
278
|
+
if args.command == 'schedules':
|
|
279
|
+
if args.schedules_command == 'list':
|
|
280
|
+
return schedules_list(home, args.all)
|
|
281
|
+
return schedules_rm(home, args.schedule_id)
|
|
282
|
+
if args.command == 'daemon':
|
|
283
|
+
return daemon(home) if args.daemon_command is None else daemon_commands.main(home, args)
|
|
284
|
+
if args.command == 'telegram':
|
|
285
|
+
return telegram_pair(home)
|
|
286
|
+
if args.command == 'mcp':
|
|
287
|
+
return mcp_commands.main(home, args)
|
|
288
|
+
if args.command == 'key':
|
|
289
|
+
return key_set(args.provider) if args.key_command == 'set' else key_rm(args.provider)
|
|
290
|
+
# New commands: each worker dispatches its own inside its block only
|
|
291
|
+
# >>> security
|
|
292
|
+
if args.command == 'review':
|
|
293
|
+
from wraithd.review import cli as review_cli
|
|
294
|
+
|
|
295
|
+
return review_cli.main(home, args)
|
|
296
|
+
# <<< security
|
|
297
|
+
# >>> efficiency
|
|
298
|
+
if args.command == 'runs_tokens':
|
|
299
|
+
from wraithd.core import tokens
|
|
300
|
+
return tokens.main(home, args.run_id, args.json)
|
|
301
|
+
# <<< efficiency
|
|
302
|
+
# >>> capabilities
|
|
303
|
+
if args.command == 'heartbeat':
|
|
304
|
+
from wraithd.daemon import heartbeat
|
|
305
|
+
|
|
306
|
+
return heartbeat.main(home, args)
|
|
307
|
+
# <<< capabilities
|
|
308
|
+
# >>> reach
|
|
309
|
+
# <<< reach
|
|
310
|
+
# >>> evals
|
|
311
|
+
# <<< evals
|
|
312
|
+
# >>> sandbox
|
|
313
|
+
# <<< sandbox
|
|
314
|
+
parser.print_help()
|
|
315
|
+
return 0
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
if __name__ == '__main__':
|
|
319
|
+
raise SystemExit(main())
|