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.
- {muse_cli-0.3.2 → muse_cli-0.3.4}/.gitignore +3 -0
- {muse_cli-0.3.2 → muse_cli-0.3.4}/PKG-INFO +24 -4
- {muse_cli-0.3.2 → muse_cli-0.3.4}/README.md +23 -3
- {muse_cli-0.3.2 → muse_cli-0.3.4}/docs/PROTOCOL.md +7 -0
- {muse_cli-0.3.2 → muse_cli-0.3.4}/skills/muse-cli/SKILL.md +9 -0
- {muse_cli-0.3.2 → muse_cli-0.3.4}/src/muse_cli/__init__.py +1 -1
- muse_cli-0.3.4/src/muse_cli/chat.py +279 -0
- {muse_cli-0.3.2 → muse_cli-0.3.4}/src/muse_cli/cli.py +222 -20
- {muse_cli-0.3.2 → muse_cli-0.3.4}/src/muse_cli/gateway.py +53 -7
- {muse_cli-0.3.2 → muse_cli-0.3.4}/LICENSE +0 -0
- {muse_cli-0.3.2 → muse_cli-0.3.4}/pyproject.toml +0 -0
- {muse_cli-0.3.2 → muse_cli-0.3.4}/src/muse_cli/__main__.py +0 -0
- {muse_cli-0.3.2 → muse_cli-0.3.4}/src/muse_cli/desc0.bin +0 -0
- {muse_cli-0.3.2 → muse_cli-0.3.4}/src/muse_cli/desc1.bin +0 -0
- {muse_cli-0.3.2 → muse_cli-0.3.4}/src/muse_cli/routes.json +0 -0
- {muse_cli-0.3.2 → muse_cli-0.3.4}/src/muse_cli/update.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: muse-cli
|
|
3
|
-
Version: 0.3.
|
|
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
|
[](https://pepy.tech/project/muse-cli)
|
|
47
47
|
[](https://github.com/nikships/muse-cli/blob/main/LICENSE)
|
|
48
48
|
[](https://github.com/nikships/muse-cli/stargazers)
|
|
49
|
-
|
|
50
|
-

|
|
49
|
+
[](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
|
+

|
|
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
|
-
|
|
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
|
[](https://pepy.tech/project/muse-cli)
|
|
10
10
|
[](https://github.com/nikships/muse-cli/blob/main/LICENSE)
|
|
11
11
|
[](https://github.com/nikships/muse-cli/stargazers)
|
|
12
|
-
|
|
13
|
-

|
|
12
|
+
[](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
|
+

|
|
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
|
-
|
|
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.
|
|
@@ -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
|
-
|
|
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(
|
|
555
|
+
time.sleep(POLL)
|
|
407
556
|
continue
|
|
408
|
-
|
|
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(
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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"
|
|
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")
|
|
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.
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|