muse-cli 0.3.2__tar.gz → 0.3.4__tar.gz

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.
@@ -14,3 +14,6 @@ config.json
14
14
 
15
15
  # OS
16
16
  .DS_Store
17
+
18
+ # Firebase local cache
19
+ .firebase/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: muse-cli
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary: Command-line client for your personal muse.ai AI agent: chat, automate, and manage side chats, feed, goals, ideas, and sessions from the terminal. No browser needed.
5
5
  Project-URL: Homepage, https://github.com/nikships/muse-cli
6
6
  Project-URL: Documentation, https://github.com/nikships/muse-cli#readme
@@ -46,11 +46,12 @@ Talk to your personal muse.ai AI agent from the terminal.
46
46
  [![Downloads](https://img.shields.io/pepy/dt/muse-cli?style=for-the-badge)](https://pepy.tech/project/muse-cli)
47
47
  [![License: MIT](https://img.shields.io/github/license/nikships/muse-cli?style=for-the-badge)](https://github.com/nikships/muse-cli/blob/main/LICENSE)
48
48
  [![GitHub stars](https://img.shields.io/github/stars/nikships/muse-cli?style=for-the-badge)](https://github.com/nikships/muse-cli/stargazers)
49
-
50
- ![muse-cli hero](https://raw.githubusercontent.com/nikships/muse-cli/main/assets/hero.webp)
49
+ [![Website](https://img.shields.io/badge/website-live-3fb950?style=for-the-badge)](https://muse-cli-site.web.app)
51
50
 
52
51
  </div>
53
52
 
53
+ https://github.com/user-attachments/assets/8c9aefe8-c5a4-4e4b-9393-48e2325f99a9
54
+
54
55
  ## What is this?
55
56
 
56
57
  A command-line client for your personal muse.ai AI agent: chat from the terminal, automate it with scripts, and manage side chats, feed, goals, ideas, and sessions without opening a browser. It speaks the app's own gateway protocol directly: HTTPS auth, then an encrypted Noise-XX WebSocket to your personal VM.
@@ -60,6 +61,14 @@ A command-line client for your personal muse.ai AI agent: chat from the terminal
60
61
  - **Full coverage:** named commands for the common tasks, plus a `raw` escape hatch for all 258 gateway methods.
61
62
  - **Agent-ready:** ships an agent skill so coding agents can drive your muse.ai agent for you.
62
63
 
64
+ ## Agents driving agents
65
+
66
+ Prompt a coding agent, it activates the muse-cli skill and runs read-only
67
+ commands for you. Live session below: a Droid worker runs `muse-cli status`
68
+ and `muse-cli goals`, then reports back.
69
+
70
+ ![Droid agent using muse-cli to check status and goals](https://raw.githubusercontent.com/nikships/muse-cli/main/assets/demo-agent.png)
71
+
63
72
  ## Quick Start
64
73
 
65
74
  ```bash
@@ -188,11 +197,17 @@ muse-cli status
188
197
  ## Usage
189
198
 
190
199
  ```bash
200
+ muse-cli # interactive chat (same as `muse-cli chat`)
191
201
  muse-cli threads # main chat + side chats
192
202
  muse-cli history --limit 5 # recent messages
193
203
  muse-cli history --thread <session-id> --limit 5 # one side chat
194
204
  muse-cli send "summarize my unread" --wait 120 # send + wait for the reply
195
205
  muse-cli watch --timeout 60 # tail live agent events
206
+ muse-cli files --limit 20 # images/videos in recent messages
207
+ muse-cli download <seq|message-id|sandbox://...> -o ./out # save agent-made files
208
+ muse-cli history --limit 3 --download ./out # save files from these messages
209
+ muse-cli send "make a cat video" --wait 300 --download ./out
210
+ muse-cli watch --thread <session-id> # tail one side chat
196
211
 
197
212
  muse-cli feed --limit 5
198
213
  muse-cli feed-react <unit-id> love
@@ -208,7 +223,11 @@ muse-cli wake
208
223
  muse-cli raw <method> --body '{}' # escape hatch: any of 258 gateway methods
209
224
  ```
210
225
 
211
- Every command prints JSON. Your VM is auto-discovered from your session, and a
226
+ The interactive chat prints plain text, one message per line, so it works well
227
+ with screen readers. It resumes the conversation you used last; type `/help`
228
+ inside it for `/chats`, `/open N`, `/main`, `/new`, `/history`, and `/login`.
229
+
230
+ Every other command prints JSON. Your VM is auto-discovered from your session, and a
212
231
  random device id is generated on first run.
213
232
 
214
233
  Pipe it into other tools:
@@ -242,6 +261,7 @@ found during reverse engineering.
242
261
  git clone https://github.com/nikships/muse-cli.git
243
262
  cd muse-cli
244
263
  uv run muse-cli --help
264
+ uv run python -m unittest discover -s tests # unit tests (no network)
245
265
  ```
246
266
 
247
267
  ```
@@ -9,11 +9,12 @@ Talk to your personal muse.ai AI agent from the terminal.
9
9
  [![Downloads](https://img.shields.io/pepy/dt/muse-cli?style=for-the-badge)](https://pepy.tech/project/muse-cli)
10
10
  [![License: MIT](https://img.shields.io/github/license/nikships/muse-cli?style=for-the-badge)](https://github.com/nikships/muse-cli/blob/main/LICENSE)
11
11
  [![GitHub stars](https://img.shields.io/github/stars/nikships/muse-cli?style=for-the-badge)](https://github.com/nikships/muse-cli/stargazers)
12
-
13
- ![muse-cli hero](https://raw.githubusercontent.com/nikships/muse-cli/main/assets/hero.webp)
12
+ [![Website](https://img.shields.io/badge/website-live-3fb950?style=for-the-badge)](https://muse-cli-site.web.app)
14
13
 
15
14
  </div>
16
15
 
16
+ https://github.com/user-attachments/assets/8c9aefe8-c5a4-4e4b-9393-48e2325f99a9
17
+
17
18
  ## What is this?
18
19
 
19
20
  A command-line client for your personal muse.ai AI agent: chat from the terminal, automate it with scripts, and manage side chats, feed, goals, ideas, and sessions without opening a browser. It speaks the app's own gateway protocol directly: HTTPS auth, then an encrypted Noise-XX WebSocket to your personal VM.
@@ -23,6 +24,14 @@ A command-line client for your personal muse.ai AI agent: chat from the terminal
23
24
  - **Full coverage:** named commands for the common tasks, plus a `raw` escape hatch for all 258 gateway methods.
24
25
  - **Agent-ready:** ships an agent skill so coding agents can drive your muse.ai agent for you.
25
26
 
27
+ ## Agents driving agents
28
+
29
+ Prompt a coding agent, it activates the muse-cli skill and runs read-only
30
+ commands for you. Live session below: a Droid worker runs `muse-cli status`
31
+ and `muse-cli goals`, then reports back.
32
+
33
+ ![Droid agent using muse-cli to check status and goals](https://raw.githubusercontent.com/nikships/muse-cli/main/assets/demo-agent.png)
34
+
26
35
  ## Quick Start
27
36
 
28
37
  ```bash
@@ -151,11 +160,17 @@ muse-cli status
151
160
  ## Usage
152
161
 
153
162
  ```bash
163
+ muse-cli # interactive chat (same as `muse-cli chat`)
154
164
  muse-cli threads # main chat + side chats
155
165
  muse-cli history --limit 5 # recent messages
156
166
  muse-cli history --thread <session-id> --limit 5 # one side chat
157
167
  muse-cli send "summarize my unread" --wait 120 # send + wait for the reply
158
168
  muse-cli watch --timeout 60 # tail live agent events
169
+ muse-cli files --limit 20 # images/videos in recent messages
170
+ muse-cli download <seq|message-id|sandbox://...> -o ./out # save agent-made files
171
+ muse-cli history --limit 3 --download ./out # save files from these messages
172
+ muse-cli send "make a cat video" --wait 300 --download ./out
173
+ muse-cli watch --thread <session-id> # tail one side chat
159
174
 
160
175
  muse-cli feed --limit 5
161
176
  muse-cli feed-react <unit-id> love
@@ -171,7 +186,11 @@ muse-cli wake
171
186
  muse-cli raw <method> --body '{}' # escape hatch: any of 258 gateway methods
172
187
  ```
173
188
 
174
- Every command prints JSON. Your VM is auto-discovered from your session, and a
189
+ The interactive chat prints plain text, one message per line, so it works well
190
+ with screen readers. It resumes the conversation you used last; type `/help`
191
+ inside it for `/chats`, `/open N`, `/main`, `/new`, `/history`, and `/login`.
192
+
193
+ Every other command prints JSON. Your VM is auto-discovered from your session, and a
175
194
  random device id is generated on first run.
176
195
 
177
196
  Pipe it into other tools:
@@ -205,6 +224,7 @@ found during reverse engineering.
205
224
  git clone https://github.com/nikships/muse-cli.git
206
225
  cd muse-cli
207
226
  uv run muse-cli --help
227
+ uv run python -m unittest discover -s tests # unit tests (no network)
208
228
  ```
209
229
 
210
230
  ```
@@ -68,6 +68,13 @@ chat / feed / goals / ideas / sessions / ...
68
68
  the stateful Noise decrypt (fatal BAD_DECRYPT). `Gateway` serializes
69
69
  receives with a lock as a backstop, but callers must still keep exactly
70
70
  one frame consumer at a time (`send` makes sequential unary calls only).
71
+ - **Agent files** (`sandbox://workspace/<p>` in `payload.data.videos[]` /
72
+ `images[]` / `fallback_text`): each entry carries `mime`, `byte_len` and
73
+ `variants.original = https://<vm>.metaaivm.com/media/raw/workspace/<p>`.
74
+ The bytes come from `GET /media/raw/workspace/<p>` on the VM. That route is
75
+ not in `routes.json`; sent as a raw-path daemon request over the Noise
76
+ tunnel it returns the file (body chunks). `Gateway.download_file()` wraps
77
+ this. `artifacts.fetch_url` is unrelated (needs `project_dir`).
71
78
  - `session.start`: `{method: "/api/session/start", params: {origin: "fresh",
72
79
  lifecycle: "persistent", title?}}`.
73
80
  - `session.rename`: flat `{session_id, title}`. pin/unpin/archive/unarchive/
@@ -138,6 +138,11 @@ muse-cli history --thread <session-id> --limit 5 # one side chat
138
138
  muse-cli send "message" --wait 120 # send + wait for the reply
139
139
  muse-cli send "message" --thread <session-id> --wait 0 # fire and forget to a side chat
140
140
  muse-cli watch --timeout 60 # tail live agent events
141
+ muse-cli watch --thread <session-id> # tail one side chat
142
+ muse-cli files --limit 20 # images/videos the agent produced
143
+ muse-cli download <seq|message-id|sandbox://...> -o DIR # save them locally
144
+ muse-cli history --limit 3 --download DIR # save files from these messages
145
+ muse-cli send "make an image of X" --wait 300 --download DIR
141
146
  muse-cli feed --limit 5
142
147
  muse-cli goals
143
148
  muse-cli ideas
@@ -169,6 +174,10 @@ only for methods with no wrapper.
169
174
  is matched as a genuine answer, not background chatter.
170
175
  - `send --wait 0` is fire-and-forget (no polling).
171
176
  - `history` without `--thread` reads the main chat only.
177
+ - Agent media shows up as `sandbox://workspace/...` paths, often in a
178
+ separate `delta.presentation` event after the text reply. `download` looks
179
+ up a message id or seq in the last `--limit` messages (main chat unless
180
+ `--thread`); existing files are skipped unless `-f`.
172
181
  - The gateway API is unversioned. Whole classes of calls failing at once means
173
182
  the protocol drifted: see `docs/PROTOCOL.md` for the re-derivation notes,
174
183
  don't guess at crypto or framing.
@@ -1,3 +1,3 @@
1
1
  """muse-cli: talk to your personal muse.ai AI agent from the terminal."""
2
2
 
3
- __version__ = "0.3.2"
3
+ __version__ = "0.3.4"
@@ -0,0 +1,279 @@
1
+ """Interactive chat with your muse.ai agent: `muse-cli chat` (or bare `muse-cli`).
2
+
3
+ Plain, line-by-line text so it reads well in a terminal and with a screen
4
+ reader. Picks up the conversation you used last. Type /help for commands.
5
+ """
6
+ import json
7
+ import os
8
+ import time
9
+ # Enable GNU readline / libedit for line editing, history, and proper
10
+ # CJK/wide-character deletion (wcwidth). It is a standard library C extension,
11
+ # but unavailable on native Windows or stripped/minimal Python installations.
12
+ try:
13
+ import readline
14
+ except ImportError:
15
+ readline = None
16
+
17
+ from .cli import CONFIG_DIR, cmd_auth_export, connect, fmt_event, load_config
18
+ from .gateway import AuthError, GatewayError
19
+
20
+ WAIT = 300 # seconds to wait for the first reply
21
+ QUIET = 10 # seconds of silence after a reply before taking the next message
22
+ POLL = 1 # seconds between history reads while waiting for a reply
23
+ STATE = os.path.join(CONFIG_DIR, "chat.json")
24
+ HISTFILE = os.path.join(CONFIG_DIR, "chat_history")
25
+
26
+ HELP = """Commands:
27
+ /chats list your conversations
28
+ /open N switch to conversation number N from /chats
29
+ /main switch to your main chat
30
+ /new [title] start a new conversation
31
+ /history [N] read the last N messages here (default 10)
32
+ /login save your muse.ai login from Chrome again (same as `muse-cli auth export`)
33
+ /exit quit (Ctrl+C works too)"""
34
+
35
+
36
+ def load_state():
37
+ try:
38
+ return json.load(open(STATE))
39
+ except (OSError, ValueError):
40
+ return {"session_id": None, "title": "main chat"}
41
+
42
+
43
+ def save_state(st):
44
+ os.makedirs(CONFIG_DIR, exist_ok=True)
45
+ json.dump(st, open(STATE, "w"), indent=2)
46
+
47
+
48
+ def is_not_found(err):
49
+ """True if a GatewayError indicates a missing or deleted session."""
50
+ return isinstance(err, GatewayError) and (
51
+ err.status == 404 or "not_found" in str(err) or "session not found" in str(err)
52
+ )
53
+
54
+
55
+ class Chat:
56
+ def __init__(self):
57
+ self.cfg = load_config()
58
+ self.st = load_state()
59
+ self.gw = connect(self.cfg)
60
+ self.listing = []
61
+
62
+ def scope(self):
63
+ return {"session_id": self.st["session_id"]} if self.st["session_id"] else {}
64
+
65
+ def events(self, limit):
66
+ evs = self.gw.call_json("chat.history", body={"limit": limit, **self.scope()})
67
+ return sorted(evs.get("chat_events", []), key=lambda e: e.get("seq", 0))
68
+
69
+ def messages(self, limit):
70
+ out = []
71
+ for ev in self.events(limit):
72
+ m = fmt_event(ev)
73
+ name = ev.get("event_name", "")
74
+ if m["text"] and name in ("message.user", "message.assistant"):
75
+ out.append(("You" if name == "message.user" else "Muse", m["text"]))
76
+ return out
77
+
78
+ def show_history(self, n=10):
79
+ try:
80
+ msgs = self.messages(n * 3)[-n:]
81
+ except GatewayError as e:
82
+ if is_not_found(e) and self.st.get("session_id"):
83
+ print(f"Chat '{self.st.get('title')}' was deleted. Switching to main chat.")
84
+ self.switch(None, "main chat")
85
+ return
86
+ raise
87
+ if not msgs:
88
+ print("No messages here yet.")
89
+ for who, text in msgs:
90
+ print(f"\n{who}: {text}")
91
+
92
+ def last_seq(self):
93
+ evs = self.events(1)
94
+ return evs[-1].get("seq", 0) if evs else 0
95
+
96
+ def new_replies(self, after):
97
+ found = []
98
+ for ev in self.events(15):
99
+ p = ev.get("payload") if isinstance(ev.get("payload"), dict) else {}
100
+ # Exclude background updates (same discriminator as cli.is_reply):
101
+ # events with reply_to_message_id are not genuine replies
102
+ if ev.get("reply_to_message_id") or p.get("reply_to_message_id"):
103
+ continue
104
+ if (ev.get("event_name") == "message.assistant" and ev.get("seq", 0) > after
105
+ and p.get("status") and fmt_event(ev)["text"]):
106
+ found.append(ev)
107
+ return found
108
+
109
+ def send(self, text):
110
+ try:
111
+ seen = self.last_seq()
112
+ body = {"items": [{"type": "text", "text": text}],
113
+ "node_id": self.cfg["node_id"], "capabilities": {}, **self.scope()}
114
+ self.gw._open("chat.stream", body=body)
115
+ except GatewayError as e:
116
+ if is_not_found(e) and self.st.get("session_id"):
117
+ print(f"Chat '{self.st.get('title')}' was deleted. Switching to main chat.")
118
+ self.switch(None, "main chat")
119
+ return
120
+ raise
121
+ print("Muse is thinking...")
122
+ deadline, got_any = time.time() + WAIT, False
123
+ while time.time() < deadline:
124
+ time.sleep(POLL)
125
+ try:
126
+ evs = self.new_replies(seen)
127
+ except (GatewayError, TimeoutError):
128
+ continue
129
+ for ev in evs:
130
+ print(f"\nMuse: {fmt_event(ev)['text']}")
131
+ seen = ev["seq"]
132
+ if evs:
133
+ got_any = True
134
+ # keep listening briefly: the agent often sends follow-ups
135
+ deadline = time.time() + QUIET
136
+ if not got_any:
137
+ print(f"(No reply within {WAIT} seconds. Type /history later to check.)")
138
+
139
+ def list_chats(self):
140
+ d = self.gw.call_json("sessions.list")
141
+ self.listing = [s for s in d.get("sessions", []) if not s.get("archived")]
142
+ for i, s in enumerate(self.listing, 1):
143
+ when = time.strftime("%Y-%m-%d %H:%M",
144
+ time.localtime(s.get("updated_at_ms", 0) / 1000))
145
+ main = "" if s.get("is_thread") else " (main chat)"
146
+ print(f"{i}. {s.get('title') or 'untitled'}{main}, updated {when}")
147
+ print("Type /open and a number to switch.")
148
+
149
+ def switch(self, session_id, title):
150
+ self.st = {"session_id": session_id, "title": title}
151
+ save_state(self.st)
152
+ print(f"Now in: {title}")
153
+ self.show_history(3)
154
+
155
+ def open_chat(self, arg):
156
+ if not self.listing:
157
+ self.list_chats()
158
+ return
159
+ try:
160
+ n = int(arg)
161
+ except ValueError:
162
+ print("Type /open and a number from /chats.")
163
+ return
164
+ if 1 <= n <= len(self.listing):
165
+ s = self.listing[n - 1]
166
+ self.switch(s["session_id"], s.get("title") or "untitled")
167
+ else:
168
+ print("No conversation with that number. Type /chats to see them.")
169
+
170
+ def new_chat(self, title):
171
+ params = {"origin": "fresh", "lifecycle": "persistent"}
172
+ if title:
173
+ params["title"] = title
174
+ d = self.gw.call_json("session.start",
175
+ body={"method": "/api/session/start", "params": params})
176
+ self.switch(d.get("session_id") or d.get("id"), title or "new conversation")
177
+
178
+ def reconnect(self):
179
+ self.gw.close()
180
+ self.gw = connect(self.cfg)
181
+
182
+ def handle(self, line):
183
+ cmd, _, arg = line.partition(" ")
184
+ arg = arg.strip()
185
+ if cmd in ("/help", "/?"):
186
+ print(HELP)
187
+ elif cmd == "/chats":
188
+ self.list_chats()
189
+ elif cmd == "/open":
190
+ self.open_chat(arg)
191
+ elif cmd == "/main":
192
+ self.switch(None, "main chat")
193
+ elif cmd == "/new":
194
+ self.new_chat(arg)
195
+ elif cmd == "/history":
196
+ self.show_history(int(arg) if arg.isdigit() else 10)
197
+ elif cmd == "/login":
198
+ if login():
199
+ self.reconnect()
200
+ print("Signed in.")
201
+ else:
202
+ print("Unknown command. Type /help.")
203
+
204
+
205
+ def login():
206
+ """Re-run `auth export`; True when fresh cookies were saved."""
207
+ try:
208
+ cmd_auth_export(None)
209
+ return True
210
+ except SystemExit:
211
+ return False
212
+
213
+
214
+ def start():
215
+ try:
216
+ return Chat()
217
+ except AuthError as e:
218
+ print(f"Can't sign in to muse.ai: {e}")
219
+ if login():
220
+ return Chat()
221
+ raise
222
+
223
+
224
+ def cmd_chat(_args):
225
+ if readline is not None:
226
+ try:
227
+ readline.read_history_file(HISTFILE)
228
+ except OSError:
229
+ pass
230
+ chat = start()
231
+ print(f"Muse chat, in: {chat.st['title']}. Type /help for commands, /exit to quit.")
232
+ try:
233
+ chat.show_history(3)
234
+ except GatewayError as e:
235
+ if is_not_found(e) and chat.st.get("session_id"):
236
+ print(f"Chat '{chat.st.get('title')}' was deleted. Switching to main chat.")
237
+ chat.switch(None, "main chat")
238
+ else:
239
+ print(f"(Could not load history: {e})")
240
+ except Exception as e:
241
+ print(f"(Could not load history: {e})")
242
+ try:
243
+ while True:
244
+ try:
245
+ line = input("\nYou: ").strip()
246
+ except EOFError:
247
+ break
248
+ if not line:
249
+ continue
250
+ if line.lower() in ("/exit", "/quit", "exit", "quit"):
251
+ break
252
+ try:
253
+ chat.handle(line) if line.startswith("/") else chat.send(line)
254
+ except AuthError:
255
+ print("Your login expired. Type /login to sign in again.")
256
+ except GatewayError as e:
257
+ if is_not_found(e) and chat.st.get("session_id"):
258
+ print(f"Chat '{chat.st.get('title')}' was deleted. Switching to main chat.")
259
+ chat.switch(None, "main chat")
260
+ else:
261
+ print(f"Gateway error: {e}")
262
+ except Exception as e: # dropped connection: reconnect so the next try works
263
+ print(f"Connection problem ({e}). Reconnected; please try again.")
264
+ try:
265
+ chat.reconnect()
266
+ except Exception as e2:
267
+ print(f"Still can't connect: {e2}")
268
+ except KeyboardInterrupt:
269
+ pass
270
+ finally:
271
+ if readline is not None:
272
+ try:
273
+ os.makedirs(CONFIG_DIR, exist_ok=True)
274
+ readline.set_history_length(1000)
275
+ readline.write_history_file(HISTFILE)
276
+ except OSError:
277
+ pass
278
+ chat.gw.close()
279
+ print("\nBye.")
@@ -11,6 +11,7 @@ Then: muse-cli status | muse-cli threads | muse-cli history | muse-cli send "hel
11
11
  import argparse
12
12
  import json
13
13
  import os
14
+ import re
14
15
  import shutil
15
16
  import sys
16
17
  import time
@@ -337,6 +338,18 @@ def cmd_history(args):
337
338
  params["session_id"] = args.thread
338
339
  d = gw.call_json("chat.history", body=params)
339
340
  events = d.get("chat_events", [])
341
+ if args.download:
342
+ res = []
343
+ for e in events:
344
+ for r in event_resources(e):
345
+ if r["path"] not in [x["path"] for x in res]:
346
+ res.append(r)
347
+ d["downloads"] = save_resources(gw, res, args.download)
348
+ if not args.raw:
349
+ rows = [fmt_event(e) for e in events if (e.get("payload", {}) or {}).get("display_text")
350
+ or (e.get("payload", {}) or {}).get("content")]
351
+ out({"messages": rows, "downloads": d["downloads"]})
352
+ return
340
353
  if args.raw:
341
354
  out(d)
342
355
  return
@@ -346,6 +359,135 @@ def cmd_history(args):
346
359
  gw.close()
347
360
 
348
361
 
362
+ # -- files the agent produced -------------------------------------------------
363
+ # Assistant messages carry media as payload.data.{videos,images,...}[]
364
+ # entries ({path: "sandbox://workspace/...", mime, byte_len, variants:
365
+ # {original: "https://<vm>.metaaivm.com/media/raw/..."}}), and as
366
+ # sandbox:// links in fallback_text.
367
+ _SANDBOX_RE = re.compile(r"sandbox://[^\s)\]\"'>]+")
368
+
369
+
370
+ def event_resources(ev):
371
+ """List the downloadable files referenced by one history event."""
372
+ p = ev.get("payload", {}) if isinstance(ev.get("payload"), dict) else {}
373
+ found = {}
374
+
375
+ def add(item, kind):
376
+ if isinstance(item, str):
377
+ item = {"path": item}
378
+ if not isinstance(item, dict):
379
+ return
380
+ path = item.get("path") or (item.get("variants") or {}).get("original")
381
+ if not path or not (path.startswith("sandbox://") or "/media/raw/" in path):
382
+ return
383
+ if item.get("missing"):
384
+ return
385
+ r = found.setdefault(path, {"path": path})
386
+ r.setdefault("kind", kind)
387
+ for k in ("mime", "byte_len", "label", "variants"):
388
+ if item.get(k) is not None:
389
+ r[k] = item[k]
390
+
391
+ data = p.get("data") if isinstance(p.get("data"), dict) else {}
392
+ for key, val in data.items():
393
+ if isinstance(val, list):
394
+ for item in val:
395
+ add(item, key.rstrip("s"))
396
+ for src in (ev.get("resources"), p.get("resources")):
397
+ for item in src or []:
398
+ add(item, "resource")
399
+ for text in (p.get("fallback_text"), data.get("fallback_text"),
400
+ p.get("display_text"), p.get("content")):
401
+ if isinstance(text, str):
402
+ for m in _SANDBOX_RE.findall(text):
403
+ add(m, "link")
404
+ return list(found.values())
405
+
406
+
407
+ def _local_name(path, directory):
408
+ name = os.path.basename(path.split("?", 1)[0].rstrip("/")) or "download"
409
+ return os.path.join(directory, name)
410
+
411
+
412
+ def save_resources(gw, resources, directory, overwrite=False):
413
+ """Download each resource into directory; return one result row each."""
414
+ os.makedirs(directory, exist_ok=True)
415
+ rows = []
416
+ for r in resources:
417
+ dest = _local_name(r["path"], directory)
418
+ row = {"path": r["path"], "file": os.path.abspath(dest)}
419
+ if os.path.exists(dest) and not overwrite:
420
+ row.update(skipped="exists", bytes=os.path.getsize(dest))
421
+ rows.append(row)
422
+ continue
423
+ try:
424
+ data = gw.download_file(r)
425
+ except (GatewayError, TimeoutError, ValueError) as e:
426
+ row["error"] = str(e)
427
+ rows.append(row)
428
+ continue
429
+ tmp = dest + ".part"
430
+ with open(tmp, "wb") as f:
431
+ f.write(data)
432
+ os.replace(tmp, dest)
433
+ row["bytes"] = len(data)
434
+ if r.get("byte_len") and r["byte_len"] != len(data):
435
+ row["warning"] = f"expected {r['byte_len']} bytes"
436
+ rows.append(row)
437
+ return rows
438
+
439
+
440
+ def _history_events(gw, thread, limit):
441
+ params = {"limit": limit}
442
+ if thread:
443
+ params["session_id"] = thread
444
+ return gw.call_json("chat.history", body=params).get("chat_events", [])
445
+
446
+
447
+ def cmd_files(args):
448
+ gw = connect(load_config())
449
+ try:
450
+ rows = []
451
+ for ev in _history_events(gw, args.thread, args.limit):
452
+ p = ev.get("payload", {}) if isinstance(ev.get("payload"), dict) else {}
453
+ for r in event_resources(ev):
454
+ rows.append({"seq": ev.get("seq"),
455
+ "message_id": p.get("message_id") or ev.get("message_id"), **r})
456
+ out(rows)
457
+ finally:
458
+ gw.close()
459
+
460
+
461
+ def cmd_download(args):
462
+ gw = connect(load_config())
463
+ try:
464
+ ref = args.ref
465
+ if ref.startswith("sandbox://") or "/media/raw/" in ref:
466
+ resources = [{"path": ref}]
467
+ else:
468
+ # A message id or history seq: collect that message's files.
469
+ resources = []
470
+ for ev in _history_events(gw, args.thread, args.limit):
471
+ p = ev.get("payload", {}) if isinstance(ev.get("payload"), dict) else {}
472
+ ids = {str(ev.get("seq")), str(p.get("message_id") or ""),
473
+ str(ev.get("message_id") or ""), str(p.get("id") or "")}
474
+ if ref in ids:
475
+ for r in event_resources(ev):
476
+ if r["path"] not in [x["path"] for x in resources]:
477
+ resources.append(r)
478
+ if not resources:
479
+ print(f"no files found for '{ref}' in the last {args.limit} messages"
480
+ + (f" of thread {args.thread}" if args.thread else " of the main chat")
481
+ + " (try --thread / --limit)", file=sys.stderr)
482
+ sys.exit(1)
483
+ rows = save_resources(gw, resources, args.output, overwrite=args.force)
484
+ out(rows)
485
+ if any("error" in r for r in rows):
486
+ sys.exit(3)
487
+ finally:
488
+ gw.close()
489
+
490
+
349
491
  def is_reply(ev, baseline):
350
492
  """True for a genuine assistant reply (not a proactive push).
351
493
 
@@ -362,11 +504,18 @@ def is_reply(ev, baseline):
362
504
  return False
363
505
  if not (p.get("display_text") or p.get("content")):
364
506
  return False
365
- if "display_text_ready" in p and not p["display_text_ready"]:
507
+ # A reply still being written has no status yet (and display_text_ready
508
+ # is already true), so its text is cut short; wait for "completed".
509
+ if not p.get("status"):
366
510
  return False
367
511
  return True
368
512
 
369
513
 
514
+ # How often a waiting send re-reads history. A history read is one small
515
+ # unary call; at 4 s a finished reply sat unshown for up to 4 s.
516
+ POLL = 1
517
+
518
+
370
519
  def cmd_send(args):
371
520
  cfg = load_config()
372
521
  gw = connect(cfg)
@@ -403,15 +552,32 @@ def cmd_send(args):
403
552
  try:
404
553
  h = gw.call_json("chat.history", body={"limit": 10, **scope})
405
554
  except (GatewayError, TimeoutError):
406
- time.sleep(4)
555
+ time.sleep(POLL)
407
556
  continue
408
- for ev in sorted(h.get("chat_events", []), key=lambda e: e.get("seq", 0)):
557
+ evs = sorted(h.get("chat_events", []), key=lambda e: e.get("seq", 0))
558
+ for ev in evs:
409
559
  if is_reply(ev, baseline):
410
560
  reply = fmt_event(ev)
411
561
  break
412
562
  if reply is None:
413
- time.sleep(4)
563
+ time.sleep(POLL)
414
564
  result = {"sent": True, "stream": stream_sid}
565
+ if reply and args.download:
566
+ # Media often lands as separate presentation events after the
567
+ # text reply; give them a moment, then save every file newer
568
+ # than the baseline.
569
+ time.sleep(min(3, max(0, deadline - time.time())))
570
+ try:
571
+ h = gw.call_json("chat.history", body={"limit": 20, **scope})
572
+ res = []
573
+ for ev in h.get("chat_events", []):
574
+ if (ev.get("seq") or 0) > baseline:
575
+ for r in event_resources(ev):
576
+ if r["path"] not in [x["path"] for x in res]:
577
+ res.append(r)
578
+ result["downloads"] = save_resources(gw, res, args.download)
579
+ except (GatewayError, TimeoutError) as e:
580
+ result["download_error"] = str(e)
415
581
  if reply:
416
582
  result["reply"] = reply
417
583
  else:
@@ -421,22 +587,34 @@ def cmd_send(args):
421
587
  gw.close()
422
588
 
423
589
 
590
+ def _print_watch_row(line, show_all):
591
+ try:
592
+ ev = json.loads(line)
593
+ except json.JSONDecodeError:
594
+ return
595
+ et = ev.get("event", ev.get("type"))
596
+ if et in ("agent.status",) and not show_all:
597
+ return
598
+ p = ev.get("payload", {}) if isinstance(ev.get("payload"), dict) else {}
599
+ row = {"event": et, "seq": ev.get("seq")}
600
+ for k in ("display_text", "content", "text", "activity_text", "status"):
601
+ if p.get(k):
602
+ row[k] = str(p[k])[:300]
603
+ print(json.dumps(row, ensure_ascii=False), flush=True)
604
+
605
+
424
606
  def cmd_watch(args):
425
607
  gw = connect(load_config())
426
608
  try:
427
- recs = gw.subscribe_json("chat.subscribe", body={"capabilities": {}},
428
- max_records=args.n, idle_timeout=8,
429
- overall_timeout=args.timeout)
430
- for ev in recs:
431
- et = ev.get("event", ev.get("type"))
432
- if et in ("agent.status",) and not args.all:
433
- continue
434
- p = ev.get("payload", {}) if isinstance(ev.get("payload"), dict) else {}
435
- row = {"event": et, "seq": ev.get("seq")}
436
- for k in ("display_text", "content", "text", "activity_text", "status"):
437
- if p.get(k):
438
- row[k] = str(p[k])[:300]
439
- print(json.dumps(row, ensure_ascii=False), flush=True)
609
+ # Subscribe the way the web client does: capabilities as a list, and
610
+ # a thread's own session id when watching a side chat.
611
+ body = {"capabilities": ["chat_cancel", "delta_stream"]}
612
+ if args.thread:
613
+ body["session_id"] = args.thread
614
+ # Rows print as each event arrives, not after the stream ends.
615
+ gw.subscribe_raw("chat.subscribe", body=body, max_records=args.n,
616
+ idle_timeout=args.timeout, overall_timeout=args.timeout,
617
+ on_record=lambda line: _print_watch_row(line, args.all))
440
618
  finally:
441
619
  gw.close()
442
620
 
@@ -622,10 +800,15 @@ def cmd_raw(args):
622
800
  gw.close()
623
801
 
624
802
 
803
+ def _chat(args):
804
+ from .chat import cmd_chat
805
+ cmd_chat(args)
806
+
807
+
625
808
  def main():
626
809
  ap = argparse.ArgumentParser(prog="muse-cli", description="CLI for your personal muse.ai agent")
627
810
  ap.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
628
- sub = ap.add_subparsers(dest="cmd", required=True)
811
+ sub = ap.add_subparsers(dest="cmd")
629
812
 
630
813
  p = sub.add_parser("auth", help="auth helpers"); a = p.add_subparsers(dest="op", required=True)
631
814
  export_help = (
@@ -655,20 +838,37 @@ def main():
655
838
  formatter_class=argparse.RawDescriptionHelpFormatter,
656
839
  ).set_defaults(fn=cmd_auth_export)
657
840
 
841
+ sub.add_parser("chat", help="interactive chat (the default with no command)").set_defaults(fn=_chat)
658
842
  sub.add_parser("status", help="VM, session count, unread, identity").set_defaults(fn=cmd_status)
659
843
  p = sub.add_parser("threads", help="list chats and side chats")
660
844
  p.add_argument("--all", dest="archived", action="store_true", help="include archived")
661
845
  p.set_defaults(fn=cmd_threads)
662
846
  p = sub.add_parser("history", help="read chat messages")
663
847
  p.add_argument("--thread", default=None); p.add_argument("--limit", type=int, default=10)
664
- p.add_argument("--raw", action="store_true"); p.set_defaults(fn=cmd_history)
848
+ p.add_argument("--raw", action="store_true")
849
+ p.add_argument("--download", metavar="DIR", default=None,
850
+ help="also save files (images/videos) from these messages into DIR")
851
+ p.set_defaults(fn=cmd_history)
665
852
  p = sub.add_parser("send", help="send the agent a message")
666
853
  p.add_argument("text"); p.add_argument("--thread", default=None)
667
854
  p.add_argument("--wait", type=int, default=90, help="seconds to wait for reply (0 = don't)")
855
+ p.add_argument("--download", metavar="DIR", default=None,
856
+ help="save files the reply produced into DIR (needs --wait)")
668
857
  p.set_defaults(fn=cmd_send)
858
+ p = sub.add_parser("files", help="list files (images/videos) in recent messages")
859
+ p.add_argument("--thread", default=None); p.add_argument("--limit", type=int, default=20)
860
+ p.set_defaults(fn=cmd_files)
861
+ p = sub.add_parser("download", help="download a file the agent produced")
862
+ p.add_argument("ref", help="sandbox:// path, media URL, message id, or history seq")
863
+ p.add_argument("-o", "--output", default=".", help="directory to save into (default .)")
864
+ p.add_argument("--thread", default=None, help="side chat to search for a message id/seq")
865
+ p.add_argument("--limit", type=int, default=50, help="messages to search for a message id/seq")
866
+ p.add_argument("-f", "--force", action="store_true", help="overwrite existing files")
867
+ p.set_defaults(fn=cmd_download)
669
868
  p = sub.add_parser("watch", help="tail live agent events")
670
869
  p.add_argument("--timeout", type=int, default=60); p.add_argument("--n", type=int, default=50)
671
- p.add_argument("--all", action="store_true"); p.set_defaults(fn=cmd_watch)
870
+ p.add_argument("--all", action="store_true"); p.add_argument("--thread", default=None)
871
+ p.set_defaults(fn=cmd_watch)
672
872
  p = sub.add_parser("feed", help="list feed units")
673
873
  p.add_argument("--limit", type=int, default=None); p.add_argument("--raw", action="store_true")
674
874
  p.set_defaults(fn=cmd_feed)
@@ -704,6 +904,8 @@ def main():
704
904
  p.add_argument("--timeout", type=int, default=30); p.set_defaults(fn=cmd_raw)
705
905
 
706
906
  args = ap.parse_args()
907
+ if args.cmd is None:
908
+ args.cmd, args.fn = "chat", _chat
707
909
  # A piped command prints JSON. The notice stays off unless both streams
708
910
  # are a terminal, and `update` is already doing the upgrade.
709
911
  notice = None if args.cmd == "update" else start_update_check()
@@ -51,6 +51,28 @@ class AuthError(RuntimeError):
51
51
  pass
52
52
 
53
53
 
54
+ def media_path(ref):
55
+ """Map a sandbox path / media URL / resource dict to a /media/raw/... path."""
56
+ if isinstance(ref, dict):
57
+ ref = (ref.get("variants") or {}).get("original") or ref.get("path") or ref.get("url")
58
+ if not ref:
59
+ raise ValueError("resource has no path or variants.original")
60
+ ref = str(ref)
61
+ if ref.startswith("sandbox://"):
62
+ rel = ref[len("sandbox://"):]
63
+ elif ref.startswith(("https://", "http://")):
64
+ rel = urllib.parse.urlsplit(ref).path
65
+ else:
66
+ rel = ref
67
+ rel = rel.lstrip("/")
68
+ if rel.startswith("media/raw/"):
69
+ rel = rel[len("media/raw/"):]
70
+ rel = urllib.parse.unquote(rel)
71
+ if not rel or ".." in rel.split("/"):
72
+ raise ValueError(f"bad sandbox path: {ref!r}")
73
+ return "/media/raw/" + urllib.parse.quote(rel, safe="/")
74
+
75
+
54
76
  class GatewayError(RuntimeError):
55
77
  def __init__(self, status, payload):
56
78
  super().__init__(f"gateway status={status} payload={payload!r}")
@@ -198,8 +220,8 @@ class Gateway:
198
220
  sf.ParseFromString(sr.payload)
199
221
  return sf
200
222
 
201
- def _open(self, method, path_params=None, body=None, query=None):
202
- route = ROUTES[method]
223
+ def _open(self, method, path_params=None, body=None, query=None, route=None):
224
+ route = route or ROUTES[method]
203
225
  path = route["path"]
204
226
  for k, v in (path_params or {}).items():
205
227
  path = path.replace("{" + k + "}", urllib.parse.quote(str(v), safe=""))
@@ -225,11 +247,11 @@ class Gateway:
225
247
  return sid
226
248
 
227
249
  # -- unary request/response --------------------------------------------
228
- def request(self, method, path_params=None, body=None, query=None, timeout=30):
229
- route = ROUTES[method]
250
+ def request(self, method, path_params=None, body=None, query=None, timeout=30, route=None):
251
+ route = route or ROUTES[method]
230
252
  if route["http"] == "GET" and body is not None and query is None:
231
253
  query, body = body, None
232
- sid = self._open(method, path_params, body, query)
254
+ sid = self._open(method, path_params, body, query, route=route)
233
255
  status, chunks, deadline = None, [], time.time() + timeout
234
256
  while time.time() < deadline:
235
257
  sf = self._read_frame()
@@ -255,6 +277,21 @@ class Gateway:
255
277
  raise GatewayError(status, payload[:500].decode("utf-8", "replace"))
256
278
  return payload
257
279
 
280
+ # -- files the agent produced (sandbox://...) ---------------------------
281
+ def download_file(self, ref, timeout=300):
282
+ """Return the bytes of a file in the agent's sandbox.
283
+
284
+ `ref` is a `sandbox://workspace/...` path, a media URL from a
285
+ resource's `variants` (https://<vm>.metaaivm.com/media/raw/...), a
286
+ bare `/media/raw/...` path, or a resource dict with `path`/`variants`.
287
+ The web app loads these from GET /media/raw/<path> on the VM; here
288
+ the same request rides the Noise tunnel (plain TLS to the per-VM
289
+ host is reset at the edge).
290
+ """
291
+ path = media_path(ref)
292
+ route = {"method": "media.raw", "http": "GET", "path": path}
293
+ return self.request("media.raw", timeout=timeout, route=route)
294
+
258
295
  def call_json(self, method, path_params=None, body=None, query=None, timeout=30):
259
296
  raw = self.request(method, path_params, body, query, timeout)
260
297
  try:
@@ -267,8 +304,13 @@ class Gateway:
267
304
 
268
305
  # -- subscriptions (ndjson event streams) -------------------------------
269
306
  def subscribe_raw(self, method, path_params=None, body=None, query=None,
270
- max_records=100, idle_timeout=10, overall_timeout=120):
271
- """Open a subscription; returns list of raw record bytes."""
307
+ max_records=100, idle_timeout=10, overall_timeout=120,
308
+ on_record=None):
309
+ """Open a subscription; returns list of raw record bytes.
310
+
311
+ ``on_record`` is called with each record the moment it arrives, for
312
+ callers that show events live instead of after the stream ends.
313
+ """
272
314
  sid = self._open(method, path_params, body, query)
273
315
  buf, records = b"", []
274
316
  q: queue_mod.Queue = queue_mod.Queue()
@@ -313,6 +355,8 @@ class Gateway:
313
355
  line, buf = buf.split(b"\n", 1)
314
356
  if line.strip():
315
357
  records.append(line)
358
+ if on_record:
359
+ on_record(line)
316
360
  if len(records) >= max_records:
317
361
  break
318
362
  finally:
@@ -320,6 +364,8 @@ class Gateway:
320
364
  stop = nonlocal_stop
321
365
  if buf.strip():
322
366
  records.append(buf)
367
+ if on_record:
368
+ on_record(buf)
323
369
  return records
324
370
 
325
371
  def subscribe_json(self, *a, **kw):
File without changes
File without changes