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
@@ -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())