soothe-cli 0.1.0__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.
- soothe_cli/__init__.py +5 -0
- soothe_cli/cli/__init__.py +1 -0
- soothe_cli/cli/commands/__init__.py +1 -0
- soothe_cli/cli/commands/autopilot_cmd.py +410 -0
- soothe_cli/cli/commands/config_cmd.py +277 -0
- soothe_cli/cli/commands/run_cmd.py +87 -0
- soothe_cli/cli/commands/status_cmd.py +121 -0
- soothe_cli/cli/commands/subagent_names.py +17 -0
- soothe_cli/cli/commands/thread_cmd.py +657 -0
- soothe_cli/cli/execution/__init__.py +6 -0
- soothe_cli/cli/execution/daemon.py +194 -0
- soothe_cli/cli/execution/headless.py +99 -0
- soothe_cli/cli/execution/launcher.py +31 -0
- soothe_cli/cli/main.py +509 -0
- soothe_cli/cli/renderer.py +444 -0
- soothe_cli/cli/stream/__init__.py +17 -0
- soothe_cli/cli/stream/context.py +138 -0
- soothe_cli/cli/stream/display_line.py +83 -0
- soothe_cli/cli/stream/formatter.py +412 -0
- soothe_cli/cli/stream/pipeline.py +521 -0
- soothe_cli/cli/utils.py +46 -0
- soothe_cli/config/__init__.py +5 -0
- soothe_cli/config/cli_config.py +155 -0
- soothe_cli/plan/__init__.py +5 -0
- soothe_cli/plan/rich_tree.py +54 -0
- soothe_cli/shared/__init__.py +107 -0
- soothe_cli/shared/command_router.py +246 -0
- soothe_cli/shared/config_loader.py +68 -0
- soothe_cli/shared/display_policy.py +413 -0
- soothe_cli/shared/essential_events.py +68 -0
- soothe_cli/shared/event_processor.py +823 -0
- soothe_cli/shared/message_processing.py +393 -0
- soothe_cli/shared/presentation_engine.py +173 -0
- soothe_cli/shared/processor_state.py +80 -0
- soothe_cli/shared/renderer_protocol.py +158 -0
- soothe_cli/shared/rendering.py +43 -0
- soothe_cli/shared/slash_commands.py +354 -0
- soothe_cli/shared/subagent_routing.py +63 -0
- soothe_cli/shared/suppression_state.py +188 -0
- soothe_cli/shared/tool_formatters/__init__.py +27 -0
- soothe_cli/shared/tool_formatters/base.py +109 -0
- soothe_cli/shared/tool_formatters/execution.py +297 -0
- soothe_cli/shared/tool_formatters/fallback.py +128 -0
- soothe_cli/shared/tool_formatters/file_ops.py +299 -0
- soothe_cli/shared/tool_formatters/goal_formatter.py +331 -0
- soothe_cli/shared/tool_formatters/media.py +291 -0
- soothe_cli/shared/tool_formatters/structured.py +202 -0
- soothe_cli/shared/tool_formatters/web.py +143 -0
- soothe_cli/shared/tool_output_formatter.py +227 -0
- soothe_cli/shared/tui_trace_log.py +40 -0
- soothe_cli/tui/__init__.py +5 -0
- soothe_cli/tui/_ask_user_types.py +50 -0
- soothe_cli/tui/_cli_context.py +27 -0
- soothe_cli/tui/_env_vars.py +56 -0
- soothe_cli/tui/_session_stats.py +114 -0
- soothe_cli/tui/_version.py +21 -0
- soothe_cli/tui/app.py +4992 -0
- soothe_cli/tui/app.tcss +302 -0
- soothe_cli/tui/command_registry.py +310 -0
- soothe_cli/tui/config.py +2381 -0
- soothe_cli/tui/daemon_session.py +233 -0
- soothe_cli/tui/file_ops.py +409 -0
- soothe_cli/tui/formatting.py +28 -0
- soothe_cli/tui/hooks.py +23 -0
- soothe_cli/tui/input.py +782 -0
- soothe_cli/tui/media_utils.py +471 -0
- soothe_cli/tui/model_config.py +518 -0
- soothe_cli/tui/output.py +69 -0
- soothe_cli/tui/project_utils.py +188 -0
- soothe_cli/tui/sessions.py +1248 -0
- soothe_cli/tui/skills/__init__.py +5 -0
- soothe_cli/tui/skills/invocation.py +74 -0
- soothe_cli/tui/skills/load.py +93 -0
- soothe_cli/tui/textual_adapter.py +1430 -0
- soothe_cli/tui/theme.py +838 -0
- soothe_cli/tui/tool_display.py +297 -0
- soothe_cli/tui/unicode_security.py +502 -0
- soothe_cli/tui/update_check.py +447 -0
- soothe_cli/tui/widgets/__init__.py +9 -0
- soothe_cli/tui/widgets/_links.py +63 -0
- soothe_cli/tui/widgets/approval.py +430 -0
- soothe_cli/tui/widgets/ask_user.py +392 -0
- soothe_cli/tui/widgets/autocomplete.py +666 -0
- soothe_cli/tui/widgets/autopilot_dashboard.py +308 -0
- soothe_cli/tui/widgets/autopilot_screen.py +64 -0
- soothe_cli/tui/widgets/chat_input.py +1834 -0
- soothe_cli/tui/widgets/clipboard.py +128 -0
- soothe_cli/tui/widgets/diff.py +240 -0
- soothe_cli/tui/widgets/editor.py +140 -0
- soothe_cli/tui/widgets/history.py +221 -0
- soothe_cli/tui/widgets/loading.py +194 -0
- soothe_cli/tui/widgets/mcp_viewer.py +352 -0
- soothe_cli/tui/widgets/message_store.py +693 -0
- soothe_cli/tui/widgets/messages.py +1720 -0
- soothe_cli/tui/widgets/model_selector.py +988 -0
- soothe_cli/tui/widgets/notification_settings.py +155 -0
- soothe_cli/tui/widgets/status.py +403 -0
- soothe_cli/tui/widgets/theme_selector.py +158 -0
- soothe_cli/tui/widgets/thread_selector.py +1865 -0
- soothe_cli/tui/widgets/tool_renderers.py +148 -0
- soothe_cli/tui/widgets/tool_widgets.py +254 -0
- soothe_cli/tui/widgets/tools.py +165 -0
- soothe_cli/tui/widgets/welcome.py +330 -0
- soothe_cli-0.1.0.dist-info/METADATA +100 -0
- soothe_cli-0.1.0.dist-info/RECORD +107 -0
- soothe_cli-0.1.0.dist-info/WHEEL +4 -0
- soothe_cli-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
"""Thread commands for Soothe CLI.
|
|
2
|
+
|
|
3
|
+
All thread operations communicate exclusively via daemon WebSocket RPC.
|
|
4
|
+
The daemon must be running for thread commands to work.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Annotated, Any
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from soothe_sdk import SOOTHE_HOME, VERBOSITY_TO_LOG_LEVEL
|
|
14
|
+
from soothe_sdk.client import WebSocketClient, is_daemon_live, websocket_url_from_config
|
|
15
|
+
|
|
16
|
+
from soothe_cli.shared import load_config
|
|
17
|
+
|
|
18
|
+
# Display limits for thread list
|
|
19
|
+
_TOPIC_DISPLAY_LIMIT = 30 # Max chars for last human message
|
|
20
|
+
_TOPIC_TRUNCATE_KEEP = 27 # Leave room for "..."
|
|
21
|
+
_THREAD_ID_DISPLAY_WIDTH = 20 # Max width for thread IDs
|
|
22
|
+
_THREAD_ID_TRUNCATE_KEEP = 17 # Leave room for "..."
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _require_daemon(ws_url: str) -> None:
|
|
26
|
+
"""Check daemon is running, exit with error if not."""
|
|
27
|
+
live = asyncio.run(_check_daemon(ws_url))
|
|
28
|
+
if not live:
|
|
29
|
+
typer.echo(
|
|
30
|
+
"Error: Daemon not running. Start with 'soothe daemon start'.",
|
|
31
|
+
err=True,
|
|
32
|
+
)
|
|
33
|
+
sys.exit(1)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def _check_daemon(ws_url: str) -> bool:
|
|
37
|
+
return await is_daemon_live(ws_url, timeout=5.0)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def _rpc(
|
|
41
|
+
ws_url: str,
|
|
42
|
+
send_fn: str,
|
|
43
|
+
send_args: dict[str, Any],
|
|
44
|
+
response_type: str,
|
|
45
|
+
timeout: float = 30.0,
|
|
46
|
+
) -> dict[str, Any]:
|
|
47
|
+
"""Send an RPC request and wait for a matching response.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
ws_url: WebSocket URL.
|
|
51
|
+
send_fn: Name of the WebSocketClient method to call.
|
|
52
|
+
send_args: Keyword arguments for the send method.
|
|
53
|
+
response_type: Expected response message type.
|
|
54
|
+
timeout: Maximum seconds to wait.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Response dict from daemon.
|
|
58
|
+
"""
|
|
59
|
+
client = WebSocketClient(url=ws_url)
|
|
60
|
+
try:
|
|
61
|
+
await client.connect()
|
|
62
|
+
method = getattr(client, send_fn)
|
|
63
|
+
await method(**send_args)
|
|
64
|
+
async with asyncio.timeout(timeout):
|
|
65
|
+
while True:
|
|
66
|
+
event = await client.read_event()
|
|
67
|
+
if not event:
|
|
68
|
+
return {"error": "Connection closed"}
|
|
69
|
+
if event.get("type") == response_type:
|
|
70
|
+
return event
|
|
71
|
+
except TimeoutError:
|
|
72
|
+
return {"error": "Timed out waiting for daemon response"}
|
|
73
|
+
finally:
|
|
74
|
+
await client.close()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _thread_status_matches_cli_filter(thread_status: str | None, status_filter: str | None) -> bool:
|
|
78
|
+
"""Match CLI ``--status`` against persisted thread status strings."""
|
|
79
|
+
if not status_filter:
|
|
80
|
+
return True
|
|
81
|
+
s = (thread_status or "").lower()
|
|
82
|
+
f = status_filter.lower()
|
|
83
|
+
if f == "active":
|
|
84
|
+
return s in ("idle", "running", "active")
|
|
85
|
+
return s == f
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _echo_thread_table(rows: list[dict[str, object]]) -> None:
|
|
89
|
+
"""Print thread table rows (from ``model_dump`` JSON or API dicts)."""
|
|
90
|
+
if not rows:
|
|
91
|
+
typer.echo("No threads.")
|
|
92
|
+
return
|
|
93
|
+
typer.echo(f"{'ID':<20} {'Status':<10} {'Created':<19} {'Last Message':<19} {'Topic':<30}")
|
|
94
|
+
typer.echo("\u2500" * 104)
|
|
95
|
+
for raw in rows:
|
|
96
|
+
tid_raw = str(raw.get("thread_id", ""))
|
|
97
|
+
tid = (
|
|
98
|
+
tid_raw
|
|
99
|
+
if len(tid_raw) <= _THREAD_ID_DISPLAY_WIDTH
|
|
100
|
+
else tid_raw[:_THREAD_ID_TRUNCATE_KEEP] + "..."
|
|
101
|
+
)
|
|
102
|
+
t_status = str(raw.get("status", ""))
|
|
103
|
+
created = str(raw.get("created_at", ""))[:19]
|
|
104
|
+
last_msg = str(raw.get("updated_at", ""))[:19]
|
|
105
|
+
last_human = raw.get("last_human_message")
|
|
106
|
+
topic_raw = str(last_human) if last_human is not None else ""
|
|
107
|
+
topic = (
|
|
108
|
+
topic_raw[:_TOPIC_TRUNCATE_KEEP] + "..."
|
|
109
|
+
if len(topic_raw) > _TOPIC_DISPLAY_LIMIT
|
|
110
|
+
else topic_raw
|
|
111
|
+
)
|
|
112
|
+
typer.echo(f"{tid:<20} {t_status:<10} {created:<19} {last_msg:<19} {topic:<30}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def thread_list(
|
|
116
|
+
config: Annotated[
|
|
117
|
+
str | None,
|
|
118
|
+
typer.Option("--config", "-c", help="Path to configuration file."),
|
|
119
|
+
] = None,
|
|
120
|
+
status: Annotated[
|
|
121
|
+
str | None,
|
|
122
|
+
typer.Option("--status", "-s", help="Filter by status (active, archived)."),
|
|
123
|
+
] = None,
|
|
124
|
+
limit: Annotated[
|
|
125
|
+
int | None,
|
|
126
|
+
typer.Option("--limit", "-l", help="Limit number of threads shown."),
|
|
127
|
+
] = None,
|
|
128
|
+
) -> None:
|
|
129
|
+
"""List all agent threads.
|
|
130
|
+
|
|
131
|
+
Examples:
|
|
132
|
+
soothe thread list
|
|
133
|
+
soothe thread list --status active
|
|
134
|
+
soothe thread list --limit 10
|
|
135
|
+
soothe thread list --limit 20 --status idle
|
|
136
|
+
"""
|
|
137
|
+
cfg = load_config(config)
|
|
138
|
+
ws_url = websocket_url_from_config(cfg)
|
|
139
|
+
_require_daemon(ws_url)
|
|
140
|
+
|
|
141
|
+
async def _list() -> None:
|
|
142
|
+
client = WebSocketClient(url=ws_url)
|
|
143
|
+
try:
|
|
144
|
+
await client.connect()
|
|
145
|
+
filter_payload: dict[str, str] | None = None
|
|
146
|
+
if status and status.lower() != "active":
|
|
147
|
+
sf = status.lower()
|
|
148
|
+
if sf in ("archived", "suspended", "idle", "running", "error"):
|
|
149
|
+
filter_payload = {"status": sf}
|
|
150
|
+
|
|
151
|
+
await client.send_thread_list(filter_payload, include_last_message=True)
|
|
152
|
+
|
|
153
|
+
async with asyncio.timeout(60.0):
|
|
154
|
+
while True:
|
|
155
|
+
event = await client.read_event()
|
|
156
|
+
if not event:
|
|
157
|
+
typer.echo("No response from daemon.", err=True)
|
|
158
|
+
return
|
|
159
|
+
if event.get("type") != "thread_list_response":
|
|
160
|
+
continue
|
|
161
|
+
threads = event.get("threads", [])
|
|
162
|
+
if not isinstance(threads, list):
|
|
163
|
+
threads = []
|
|
164
|
+
filtered = [
|
|
165
|
+
t
|
|
166
|
+
for t in threads
|
|
167
|
+
if isinstance(t, dict)
|
|
168
|
+
and _thread_status_matches_cli_filter(t.get("status"), status)
|
|
169
|
+
]
|
|
170
|
+
filtered.sort(key=lambda x: str(x.get("updated_at", "")), reverse=True)
|
|
171
|
+
if limit is not None and limit > 0:
|
|
172
|
+
filtered = filtered[:limit]
|
|
173
|
+
_echo_thread_table(filtered)
|
|
174
|
+
return
|
|
175
|
+
except TimeoutError:
|
|
176
|
+
typer.echo("Timed out waiting for thread list from daemon.", err=True)
|
|
177
|
+
finally:
|
|
178
|
+
await client.close()
|
|
179
|
+
|
|
180
|
+
asyncio.run(_list())
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def thread_continue(
|
|
184
|
+
thread_id: Annotated[
|
|
185
|
+
str | None,
|
|
186
|
+
typer.Argument(help="Thread ID to continue. Omit to continue last active thread."),
|
|
187
|
+
] = None,
|
|
188
|
+
config: Annotated[
|
|
189
|
+
str | None,
|
|
190
|
+
typer.Option("--config", "-c", help="Path to configuration file."),
|
|
191
|
+
] = None,
|
|
192
|
+
*,
|
|
193
|
+
new: Annotated[
|
|
194
|
+
bool,
|
|
195
|
+
typer.Option("--new", help="Create a new thread instead of continuing."),
|
|
196
|
+
] = False,
|
|
197
|
+
) -> None:
|
|
198
|
+
"""Continue a conversation thread in the TUI.
|
|
199
|
+
|
|
200
|
+
Requires a running daemon. Start daemon with 'soothe daemon start' first.
|
|
201
|
+
|
|
202
|
+
Examples:
|
|
203
|
+
soothe thread continue abc123
|
|
204
|
+
soothe thread continue --new
|
|
205
|
+
soothe thread continue
|
|
206
|
+
"""
|
|
207
|
+
from soothe_cli.cli.execution import run_tui
|
|
208
|
+
from soothe_cli.shared import setup_logging
|
|
209
|
+
|
|
210
|
+
cfg = load_config(config)
|
|
211
|
+
log_level = VERBOSITY_TO_LOG_LEVEL.get(cfg.logging.verbosity, "INFO")
|
|
212
|
+
log_file = Path(SOOTHE_HOME) / "logs" / "soothe-cli.log"
|
|
213
|
+
setup_logging(log_level, log_file=log_file)
|
|
214
|
+
ws_url = websocket_url_from_config(cfg)
|
|
215
|
+
_require_daemon(ws_url)
|
|
216
|
+
|
|
217
|
+
# Handle --new flag
|
|
218
|
+
if new:
|
|
219
|
+
thread_id = None
|
|
220
|
+
elif not thread_id:
|
|
221
|
+
# Find the most recently updated active thread through the daemon
|
|
222
|
+
async def get_last_thread_via_daemon() -> str | None:
|
|
223
|
+
client = WebSocketClient(url=ws_url)
|
|
224
|
+
try:
|
|
225
|
+
await client.connect()
|
|
226
|
+
await client.send_thread_list()
|
|
227
|
+
while True:
|
|
228
|
+
event = await client.read_event()
|
|
229
|
+
if not event:
|
|
230
|
+
break
|
|
231
|
+
if event.get("type") != "thread_list_response":
|
|
232
|
+
continue
|
|
233
|
+
threads = event.get("threads", [])
|
|
234
|
+
active_threads = [t for t in threads if t.get("status") in ("active", "idle")]
|
|
235
|
+
if not active_threads:
|
|
236
|
+
typer.echo("No active threads found.", err=True)
|
|
237
|
+
sys.exit(1)
|
|
238
|
+
active_threads.sort(key=lambda x: x.get("updated_at", ""), reverse=True)
|
|
239
|
+
return active_threads[0].get("thread_id")
|
|
240
|
+
finally:
|
|
241
|
+
await client.close()
|
|
242
|
+
|
|
243
|
+
typer.echo("No active threads found.", err=True)
|
|
244
|
+
sys.exit(1)
|
|
245
|
+
|
|
246
|
+
thread_id = asyncio.run(get_last_thread_via_daemon())
|
|
247
|
+
|
|
248
|
+
run_tui(cfg, thread_id=thread_id, config_path=config)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def thread_archive(
|
|
252
|
+
thread_id: Annotated[str, typer.Argument(help="Thread ID to archive.")],
|
|
253
|
+
config: Annotated[
|
|
254
|
+
str | None,
|
|
255
|
+
typer.Option("--config", "-c", help="Path to configuration file."),
|
|
256
|
+
] = None,
|
|
257
|
+
) -> None:
|
|
258
|
+
"""Archive a thread.
|
|
259
|
+
|
|
260
|
+
Example:
|
|
261
|
+
soothe thread archive abc123
|
|
262
|
+
"""
|
|
263
|
+
cfg = load_config(config)
|
|
264
|
+
ws_url = websocket_url_from_config(cfg)
|
|
265
|
+
_require_daemon(ws_url)
|
|
266
|
+
|
|
267
|
+
resp = asyncio.run(
|
|
268
|
+
_rpc(ws_url, "send_thread_archive", {"thread_id": thread_id}, "thread_operation_ack")
|
|
269
|
+
)
|
|
270
|
+
if resp.get("success"):
|
|
271
|
+
typer.echo(f"Archived thread {thread_id}.")
|
|
272
|
+
else:
|
|
273
|
+
typer.echo(
|
|
274
|
+
f"Failed to archive thread: {resp.get('message', resp.get('error', 'unknown'))}",
|
|
275
|
+
err=True,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def thread_show(
|
|
280
|
+
thread_id: Annotated[str, typer.Argument(help="Thread ID to show.")],
|
|
281
|
+
config: Annotated[
|
|
282
|
+
str | None,
|
|
283
|
+
typer.Option("--config", "-c", help="Path to configuration file."),
|
|
284
|
+
] = None,
|
|
285
|
+
) -> None:
|
|
286
|
+
"""Show thread details.
|
|
287
|
+
|
|
288
|
+
Example:
|
|
289
|
+
soothe thread show abc123
|
|
290
|
+
"""
|
|
291
|
+
cfg = load_config(config)
|
|
292
|
+
ws_url = websocket_url_from_config(cfg)
|
|
293
|
+
_require_daemon(ws_url)
|
|
294
|
+
|
|
295
|
+
async def _show() -> None:
|
|
296
|
+
client = WebSocketClient(url=ws_url)
|
|
297
|
+
try:
|
|
298
|
+
await client.connect()
|
|
299
|
+
|
|
300
|
+
# Get thread metadata
|
|
301
|
+
await client.send_thread_get(thread_id)
|
|
302
|
+
async with asyncio.timeout(30.0):
|
|
303
|
+
while True:
|
|
304
|
+
event = await client.read_event()
|
|
305
|
+
if not event:
|
|
306
|
+
typer.echo("No response from daemon.", err=True)
|
|
307
|
+
return
|
|
308
|
+
etype = event.get("type", "")
|
|
309
|
+
if etype == "thread_get_response":
|
|
310
|
+
thread = event.get("thread", {})
|
|
311
|
+
typer.echo(f"Thread ID: {thread.get('thread_id', thread_id)}")
|
|
312
|
+
typer.echo(f"Status: {thread.get('status', 'unknown')}")
|
|
313
|
+
typer.echo(f"Created: {thread.get('created_at', 'unknown')}")
|
|
314
|
+
typer.echo(f"Updated: {thread.get('updated_at', 'unknown')}")
|
|
315
|
+
metadata = thread.get("metadata", {})
|
|
316
|
+
if metadata.get("tags"):
|
|
317
|
+
typer.echo(f"Tags: {', '.join(metadata['tags'])}")
|
|
318
|
+
return
|
|
319
|
+
if etype == "error":
|
|
320
|
+
typer.echo(f"Error: {event.get('message', 'unknown')}", err=True)
|
|
321
|
+
return
|
|
322
|
+
except TimeoutError:
|
|
323
|
+
typer.echo("Timed out waiting for response.", err=True)
|
|
324
|
+
finally:
|
|
325
|
+
await client.close()
|
|
326
|
+
|
|
327
|
+
asyncio.run(_show())
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def thread_delete(
|
|
331
|
+
thread_id: Annotated[str, typer.Argument(help="Thread ID to delete.")],
|
|
332
|
+
config: Annotated[
|
|
333
|
+
str | None,
|
|
334
|
+
typer.Option("--config", "-c", help="Path to configuration file."),
|
|
335
|
+
] = None,
|
|
336
|
+
*,
|
|
337
|
+
yes: Annotated[
|
|
338
|
+
bool,
|
|
339
|
+
typer.Option("--yes", "-y", help="Skip confirmation."),
|
|
340
|
+
] = False,
|
|
341
|
+
) -> None:
|
|
342
|
+
"""Permanently delete a thread.
|
|
343
|
+
|
|
344
|
+
Example:
|
|
345
|
+
soothe thread delete abc123
|
|
346
|
+
"""
|
|
347
|
+
if not yes:
|
|
348
|
+
confirm = typer.confirm(f"Permanently delete thread {thread_id}?")
|
|
349
|
+
if not confirm:
|
|
350
|
+
typer.echo("Cancelled.")
|
|
351
|
+
return
|
|
352
|
+
|
|
353
|
+
cfg = load_config(config)
|
|
354
|
+
ws_url = websocket_url_from_config(cfg)
|
|
355
|
+
_require_daemon(ws_url)
|
|
356
|
+
|
|
357
|
+
resp = asyncio.run(
|
|
358
|
+
_rpc(ws_url, "send_thread_delete", {"thread_id": thread_id}, "thread_operation_ack")
|
|
359
|
+
)
|
|
360
|
+
if resp.get("success"):
|
|
361
|
+
typer.echo(f"Deleted thread {thread_id}.")
|
|
362
|
+
else:
|
|
363
|
+
typer.echo(
|
|
364
|
+
f"Failed to delete thread: {resp.get('message', resp.get('error', 'unknown'))}",
|
|
365
|
+
err=True,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def thread_export(
|
|
370
|
+
thread_id: Annotated[str, typer.Argument(help="Thread ID to export.")],
|
|
371
|
+
output: Annotated[
|
|
372
|
+
str | None,
|
|
373
|
+
typer.Option("--output", "-o", help="Output file path."),
|
|
374
|
+
] = None,
|
|
375
|
+
export_format: Annotated[
|
|
376
|
+
str,
|
|
377
|
+
typer.Option("--format", "-f", help="Export format: jsonl or md."),
|
|
378
|
+
] = "jsonl",
|
|
379
|
+
) -> None:
|
|
380
|
+
"""Export thread conversation to a file.
|
|
381
|
+
|
|
382
|
+
Example:
|
|
383
|
+
soothe thread export abc123 --output out.jsonl
|
|
384
|
+
soothe thread export abc123 --format md --output out.md
|
|
385
|
+
"""
|
|
386
|
+
import json
|
|
387
|
+
from pathlib import Path
|
|
388
|
+
|
|
389
|
+
cfg = load_config(config=None)
|
|
390
|
+
ws_url = websocket_url_from_config(cfg)
|
|
391
|
+
_require_daemon(ws_url)
|
|
392
|
+
|
|
393
|
+
async def _export() -> None:
|
|
394
|
+
client = WebSocketClient(url=ws_url)
|
|
395
|
+
try:
|
|
396
|
+
await client.connect()
|
|
397
|
+
await client.send_thread_messages(thread_id, limit=10000)
|
|
398
|
+
async with asyncio.timeout(60.0):
|
|
399
|
+
while True:
|
|
400
|
+
event = await client.read_event()
|
|
401
|
+
if not event:
|
|
402
|
+
typer.echo("No response from daemon.", err=True)
|
|
403
|
+
return
|
|
404
|
+
if event.get("type") != "thread_messages_response":
|
|
405
|
+
continue
|
|
406
|
+
messages = event.get("messages", [])
|
|
407
|
+
if not messages:
|
|
408
|
+
typer.echo(f"No messages found for thread {thread_id}.")
|
|
409
|
+
return
|
|
410
|
+
|
|
411
|
+
if export_format == "md":
|
|
412
|
+
lines = [f"# Thread {thread_id}\n"]
|
|
413
|
+
for msg in messages:
|
|
414
|
+
role = msg.get("type", msg.get("role", "unknown"))
|
|
415
|
+
content = msg.get("content", "")
|
|
416
|
+
if isinstance(content, list):
|
|
417
|
+
content = "\n".join(
|
|
418
|
+
str(c.get("text", c)) if isinstance(c, dict) else str(c)
|
|
419
|
+
for c in content
|
|
420
|
+
)
|
|
421
|
+
lines.append(f"\n## {role}\n\n{content}\n")
|
|
422
|
+
text = "\n".join(lines)
|
|
423
|
+
else:
|
|
424
|
+
text = "\n".join(json.dumps(msg) for msg in messages) + "\n"
|
|
425
|
+
|
|
426
|
+
if output:
|
|
427
|
+
Path(output).write_text(text, encoding="utf-8")
|
|
428
|
+
typer.echo(f"Exported {len(messages)} messages to {output}")
|
|
429
|
+
else:
|
|
430
|
+
typer.echo(text)
|
|
431
|
+
return
|
|
432
|
+
except TimeoutError:
|
|
433
|
+
typer.echo("Timed out waiting for messages.", err=True)
|
|
434
|
+
finally:
|
|
435
|
+
await client.close()
|
|
436
|
+
|
|
437
|
+
asyncio.run(_export())
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def thread_stats(
|
|
441
|
+
thread_id: Annotated[str, typer.Argument(help="Thread ID.")],
|
|
442
|
+
config: Annotated[
|
|
443
|
+
str | None,
|
|
444
|
+
typer.Option("--config", "-c", help="Path to configuration file."),
|
|
445
|
+
] = None,
|
|
446
|
+
) -> None:
|
|
447
|
+
"""Show thread execution statistics.
|
|
448
|
+
|
|
449
|
+
Example:
|
|
450
|
+
soothe thread stats abc123
|
|
451
|
+
"""
|
|
452
|
+
cfg = load_config(config)
|
|
453
|
+
ws_url = websocket_url_from_config(cfg)
|
|
454
|
+
_require_daemon(ws_url)
|
|
455
|
+
|
|
456
|
+
async def _stats() -> None:
|
|
457
|
+
client = WebSocketClient(url=ws_url)
|
|
458
|
+
try:
|
|
459
|
+
await client.connect()
|
|
460
|
+
# Use thread_get with stats included via thread_list
|
|
461
|
+
await client.send_thread_list(
|
|
462
|
+
{"thread_id": thread_id}, include_stats=True, include_last_message=True
|
|
463
|
+
)
|
|
464
|
+
async with asyncio.timeout(30.0):
|
|
465
|
+
while True:
|
|
466
|
+
event = await client.read_event()
|
|
467
|
+
if not event:
|
|
468
|
+
typer.echo("No response from daemon.", err=True)
|
|
469
|
+
return
|
|
470
|
+
if event.get("type") != "thread_list_response":
|
|
471
|
+
continue
|
|
472
|
+
threads = event.get("threads", [])
|
|
473
|
+
match = [t for t in threads if t.get("thread_id") == thread_id]
|
|
474
|
+
if not match:
|
|
475
|
+
typer.echo(f"Thread {thread_id} not found.")
|
|
476
|
+
return
|
|
477
|
+
t = match[0]
|
|
478
|
+
typer.echo(f"Thread: {thread_id}")
|
|
479
|
+
typer.echo(f"Status: {t.get('status', 'unknown')}")
|
|
480
|
+
typer.echo(f"Created: {t.get('created_at', 'unknown')}")
|
|
481
|
+
typer.echo(f"Updated: {t.get('updated_at', 'unknown')}")
|
|
482
|
+
stats = t.get("stats", {})
|
|
483
|
+
if stats:
|
|
484
|
+
typer.echo(f"Messages: {stats.get('message_count', 'N/A')}")
|
|
485
|
+
typer.echo(f"Events: {stats.get('event_count', 'N/A')}")
|
|
486
|
+
typer.echo(f"Artifacts: {stats.get('artifact_count', 'N/A')}")
|
|
487
|
+
typer.echo(f"Errors: {stats.get('error_count', 'N/A')}")
|
|
488
|
+
return
|
|
489
|
+
except TimeoutError:
|
|
490
|
+
typer.echo("Timed out waiting for response.", err=True)
|
|
491
|
+
finally:
|
|
492
|
+
await client.close()
|
|
493
|
+
|
|
494
|
+
asyncio.run(_stats())
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def thread_tag(
|
|
498
|
+
thread_id: Annotated[str, typer.Argument(help="Thread ID.")],
|
|
499
|
+
tags: Annotated[
|
|
500
|
+
list[str],
|
|
501
|
+
typer.Argument(help="Tags to add/remove."),
|
|
502
|
+
],
|
|
503
|
+
config: Annotated[
|
|
504
|
+
str | None,
|
|
505
|
+
typer.Option("--config", "-c", help="Path to configuration file."),
|
|
506
|
+
] = None,
|
|
507
|
+
*,
|
|
508
|
+
remove: Annotated[
|
|
509
|
+
bool,
|
|
510
|
+
typer.Option("--remove", help="Remove tags instead of adding."),
|
|
511
|
+
] = False,
|
|
512
|
+
) -> None:
|
|
513
|
+
"""Add or remove tags from a thread.
|
|
514
|
+
|
|
515
|
+
Examples:
|
|
516
|
+
soothe thread tag abc123 research analysis
|
|
517
|
+
soothe thread tag abc123 research --remove
|
|
518
|
+
"""
|
|
519
|
+
cfg = load_config(config)
|
|
520
|
+
ws_url = websocket_url_from_config(cfg)
|
|
521
|
+
_require_daemon(ws_url)
|
|
522
|
+
|
|
523
|
+
async def _tag() -> None:
|
|
524
|
+
client = WebSocketClient(url=ws_url)
|
|
525
|
+
try:
|
|
526
|
+
await client.connect()
|
|
527
|
+
|
|
528
|
+
# Get current thread state to read existing tags
|
|
529
|
+
await client.send_thread_get(thread_id)
|
|
530
|
+
thread_data: dict[str, Any] = {}
|
|
531
|
+
async with asyncio.timeout(30.0):
|
|
532
|
+
while True:
|
|
533
|
+
event = await client.read_event()
|
|
534
|
+
if not event:
|
|
535
|
+
typer.echo("No response from daemon.", err=True)
|
|
536
|
+
return
|
|
537
|
+
etype = event.get("type", "")
|
|
538
|
+
if etype == "thread_get_response":
|
|
539
|
+
thread_data = event.get("thread", {})
|
|
540
|
+
break
|
|
541
|
+
if etype == "error":
|
|
542
|
+
typer.echo(f"Error: {event.get('message', 'unknown')}", err=True)
|
|
543
|
+
return
|
|
544
|
+
|
|
545
|
+
# Update tags
|
|
546
|
+
metadata = dict(thread_data.get("metadata", {}))
|
|
547
|
+
current_tags = set(metadata.get("tags", []))
|
|
548
|
+
|
|
549
|
+
if remove:
|
|
550
|
+
current_tags -= set(tags)
|
|
551
|
+
else:
|
|
552
|
+
current_tags |= set(tags)
|
|
553
|
+
|
|
554
|
+
metadata["tags"] = sorted(current_tags)
|
|
555
|
+
|
|
556
|
+
# Update state via thread_update_state
|
|
557
|
+
await client.send_thread_update_state(thread_id, {"metadata": metadata})
|
|
558
|
+
|
|
559
|
+
# Wait for ack
|
|
560
|
+
async with asyncio.timeout(10.0):
|
|
561
|
+
while True:
|
|
562
|
+
event = await client.read_event()
|
|
563
|
+
if not event:
|
|
564
|
+
break
|
|
565
|
+
if event.get("type") in (
|
|
566
|
+
"thread_update_state_response",
|
|
567
|
+
"thread_operation_ack",
|
|
568
|
+
):
|
|
569
|
+
break
|
|
570
|
+
|
|
571
|
+
tag_list = ", ".join(metadata["tags"]) if metadata["tags"] else "(none)"
|
|
572
|
+
typer.echo(f"Tags: {tag_list}")
|
|
573
|
+
except TimeoutError:
|
|
574
|
+
typer.echo("Timed out waiting for response.", err=True)
|
|
575
|
+
finally:
|
|
576
|
+
await client.close()
|
|
577
|
+
|
|
578
|
+
asyncio.run(_tag())
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def thread_create(
|
|
582
|
+
config: Annotated[
|
|
583
|
+
str | None,
|
|
584
|
+
typer.Option("--config", "-c", help="Path to configuration file."),
|
|
585
|
+
] = None,
|
|
586
|
+
*,
|
|
587
|
+
message: Annotated[
|
|
588
|
+
str | None,
|
|
589
|
+
typer.Option("--message", "-m", help="Initial message to seed the thread."),
|
|
590
|
+
] = None,
|
|
591
|
+
tag: Annotated[
|
|
592
|
+
list[str] | None,
|
|
593
|
+
typer.Option("--tag", "-t", help="Tags for the thread (repeatable)."),
|
|
594
|
+
] = None,
|
|
595
|
+
) -> None:
|
|
596
|
+
"""Create a new persisted thread.
|
|
597
|
+
|
|
598
|
+
Examples:
|
|
599
|
+
soothe thread create
|
|
600
|
+
soothe thread create --message "Hello world"
|
|
601
|
+
soothe thread create --tag research --tag analysis
|
|
602
|
+
"""
|
|
603
|
+
cfg = load_config(config)
|
|
604
|
+
ws_url = websocket_url_from_config(cfg)
|
|
605
|
+
_require_daemon(ws_url)
|
|
606
|
+
|
|
607
|
+
metadata: dict[str, Any] | None = None
|
|
608
|
+
if tag:
|
|
609
|
+
metadata = {"tags": sorted(tag)}
|
|
610
|
+
|
|
611
|
+
resp = asyncio.run(
|
|
612
|
+
_rpc(
|
|
613
|
+
ws_url,
|
|
614
|
+
"send_thread_create",
|
|
615
|
+
{"initial_message": message, "metadata": metadata},
|
|
616
|
+
"thread_created",
|
|
617
|
+
)
|
|
618
|
+
)
|
|
619
|
+
if resp.get("thread_id"):
|
|
620
|
+
typer.echo(f"Created thread {resp['thread_id']}")
|
|
621
|
+
else:
|
|
622
|
+
typer.echo(
|
|
623
|
+
f"Failed to create thread: {resp.get('message', resp.get('error', 'unknown'))}",
|
|
624
|
+
err=True,
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def thread_artifacts(
|
|
629
|
+
thread_id: Annotated[str, typer.Argument(help="Thread ID to list artifacts for.")],
|
|
630
|
+
config: Annotated[
|
|
631
|
+
str | None,
|
|
632
|
+
typer.Option("--config", "-c", help="Path to configuration file."),
|
|
633
|
+
] = None,
|
|
634
|
+
) -> None:
|
|
635
|
+
"""List artifacts for a thread.
|
|
636
|
+
|
|
637
|
+
Example:
|
|
638
|
+
soothe thread artifacts abc123
|
|
639
|
+
"""
|
|
640
|
+
cfg = load_config(config)
|
|
641
|
+
ws_url = websocket_url_from_config(cfg)
|
|
642
|
+
_require_daemon(ws_url)
|
|
643
|
+
|
|
644
|
+
resp = asyncio.run(
|
|
645
|
+
_rpc(ws_url, "send_thread_artifacts", {"thread_id": thread_id}, "thread_artifacts_response")
|
|
646
|
+
)
|
|
647
|
+
artifacts = resp.get("artifacts", [])
|
|
648
|
+
if not artifacts:
|
|
649
|
+
typer.echo("No artifacts found.")
|
|
650
|
+
return
|
|
651
|
+
typer.echo(f"{'Name':<30} {'Type':<15} {'Summary':<40}")
|
|
652
|
+
typer.echo("\u2500" * 90)
|
|
653
|
+
for a in artifacts:
|
|
654
|
+
name = str(a.get("name", ""))[:30]
|
|
655
|
+
a_type = str(a.get("type", ""))[:15]
|
|
656
|
+
summary = str(a.get("summary", ""))[:40]
|
|
657
|
+
typer.echo(f"{name:<30} {a_type:<15} {summary:<40}")
|