callwalkietalkie 0.8.0
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.
- package/README.md +36 -0
- package/bin/callwalkietalkie.js +340 -0
- package/bin/longleash.js +3 -0
- package/package.json +42 -0
- package/runtime/agent_ui.html +1118 -0
- package/runtime/cmux-adapter/cmux_adapter.py +985 -0
- package/runtime/requirements.txt +3 -0
- package/runtime/winproxy.py +960 -0
- package/scripts/sync-runtime.mjs +33 -0
|
@@ -0,0 +1,985 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
cmux adapter — thin read/write chat over cmux's Unix socket.
|
|
4
|
+
|
|
5
|
+
Opinionated: cmux is the host. We pick a workspace/surface, send text into the
|
|
6
|
+
terminal, and parse scrollback into chat + command blocks for the phone UI.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import socket
|
|
16
|
+
import uuid
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Optional
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _socket_candidates() -> list[str]:
|
|
23
|
+
# Re-read env each call — empty strings are not valid paths
|
|
24
|
+
# (Path("").exists() is True on macOS, which would falsely "find" cwd).
|
|
25
|
+
out: list[str] = []
|
|
26
|
+
for path in (
|
|
27
|
+
os.environ.get("CMUX_SOCKET_PATH") or "",
|
|
28
|
+
os.environ.get("CMUX_SOCKET") or "",
|
|
29
|
+
str(Path.home() / ".local/state/cmux/cmux.sock"),
|
|
30
|
+
"/tmp/cmux.sock",
|
|
31
|
+
"/tmp/cmux-debug.sock",
|
|
32
|
+
):
|
|
33
|
+
path = path.strip()
|
|
34
|
+
if path and path not in out:
|
|
35
|
+
out.append(path)
|
|
36
|
+
return out
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CmuxError(RuntimeError):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def find_socket() -> str:
|
|
44
|
+
for path in _socket_candidates():
|
|
45
|
+
try:
|
|
46
|
+
if Path(path).is_socket() or Path(path).exists():
|
|
47
|
+
# Prefer real sockets; skip directories / empty-path weirdness.
|
|
48
|
+
if Path(path).is_dir():
|
|
49
|
+
continue
|
|
50
|
+
return path
|
|
51
|
+
except OSError:
|
|
52
|
+
continue
|
|
53
|
+
raise CmuxError(
|
|
54
|
+
"cmux is not running (no socket).\n"
|
|
55
|
+
"Open cmux, then re-run. Socket expected at ~/.local/state/cmux/cmux.sock"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _recv_json_line(sock: socket.socket) -> dict:
|
|
60
|
+
"""Read newline-delimited JSON, skipping blank lines / partial frames."""
|
|
61
|
+
buf = b""
|
|
62
|
+
while True:
|
|
63
|
+
chunk = sock.recv(1 << 20)
|
|
64
|
+
if not chunk:
|
|
65
|
+
break
|
|
66
|
+
buf += chunk
|
|
67
|
+
while b"\n" in buf:
|
|
68
|
+
raw_line, buf = buf.split(b"\n", 1)
|
|
69
|
+
line = raw_line.strip()
|
|
70
|
+
if not line:
|
|
71
|
+
continue
|
|
72
|
+
try:
|
|
73
|
+
return json.loads(line)
|
|
74
|
+
except json.JSONDecodeError:
|
|
75
|
+
# Incomplete JSON that happened to contain a newline — keep reading.
|
|
76
|
+
buf = raw_line + b"\n" + buf
|
|
77
|
+
break
|
|
78
|
+
# Fast path: whole buffer is one JSON object (no trailing newline yet).
|
|
79
|
+
stripped = buf.strip()
|
|
80
|
+
if stripped.startswith(b"{") and stripped.endswith(b"}"):
|
|
81
|
+
try:
|
|
82
|
+
return json.loads(stripped)
|
|
83
|
+
except json.JSONDecodeError:
|
|
84
|
+
pass
|
|
85
|
+
preview = buf[:120].decode("utf-8", "replace")
|
|
86
|
+
if "Access denied" in preview:
|
|
87
|
+
raise CmuxError(
|
|
88
|
+
"cmux blocked this process (socket is cmux-only).\n"
|
|
89
|
+
"In ~/.config/cmux/cmux.json set automation.socketControlMode to "
|
|
90
|
+
'"automation", then Quit and reopen cmux.\n'
|
|
91
|
+
"npx callwalkietalkie has to run outside a cmux terminal, so the default "
|
|
92
|
+
"cmuxOnly mode rejects it."
|
|
93
|
+
)
|
|
94
|
+
raise CmuxError(f"empty/partial response from cmux ({preview!r})")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _rpc_socket(method: str, params: Optional[dict], path: str, timeout: float) -> Any:
|
|
98
|
+
payload = {
|
|
99
|
+
"id": str(uuid.uuid4()),
|
|
100
|
+
"method": method,
|
|
101
|
+
"params": params or {},
|
|
102
|
+
}
|
|
103
|
+
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8") + b"\n"
|
|
104
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
|
105
|
+
sock.settimeout(timeout)
|
|
106
|
+
sock.connect(path)
|
|
107
|
+
sock.sendall(raw)
|
|
108
|
+
return _recv_json_line(sock)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _rpc_cli(method: str, params: Optional[dict], path: str, timeout: float) -> Any:
|
|
112
|
+
"""Fallback via `cmux rpc` when the raw socket returns junk."""
|
|
113
|
+
import subprocess
|
|
114
|
+
|
|
115
|
+
cli = (
|
|
116
|
+
os.environ.get("CMUX_BUNDLED_CLI_PATH")
|
|
117
|
+
or "/Applications/cmux.app/Contents/Resources/bin/cmux"
|
|
118
|
+
)
|
|
119
|
+
if not Path(cli).exists():
|
|
120
|
+
cli = "cmux"
|
|
121
|
+
env = os.environ.copy()
|
|
122
|
+
env["CMUX_SOCKET_PATH"] = path
|
|
123
|
+
# Don't pass a blank CMUX_SOCKET through — it confuses some builds.
|
|
124
|
+
if not (env.get("CMUX_SOCKET") or "").strip():
|
|
125
|
+
env.pop("CMUX_SOCKET", None)
|
|
126
|
+
proc = subprocess.run(
|
|
127
|
+
[cli, "--socket", path, "rpc", method, json.dumps(params or {}), "--json"],
|
|
128
|
+
capture_output=True,
|
|
129
|
+
text=True,
|
|
130
|
+
timeout=timeout,
|
|
131
|
+
env=env,
|
|
132
|
+
)
|
|
133
|
+
out = (proc.stdout or "").strip()
|
|
134
|
+
if not out:
|
|
135
|
+
err = (proc.stderr or "").strip() or f"cmux rpc exited {proc.returncode}"
|
|
136
|
+
raise CmuxError(err)
|
|
137
|
+
# CLI may print a deprecation line before JSON — take the last JSON object.
|
|
138
|
+
data = None
|
|
139
|
+
for line in out.splitlines():
|
|
140
|
+
line = line.strip()
|
|
141
|
+
if line.startswith("{"):
|
|
142
|
+
try:
|
|
143
|
+
data = json.loads(line)
|
|
144
|
+
except json.JSONDecodeError:
|
|
145
|
+
continue
|
|
146
|
+
if data is None:
|
|
147
|
+
try:
|
|
148
|
+
data = json.loads(out)
|
|
149
|
+
except json.JSONDecodeError as e:
|
|
150
|
+
raise CmuxError(f"bad cmux CLI JSON: {e}") from e
|
|
151
|
+
return data
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def rpc(
|
|
155
|
+
method: str,
|
|
156
|
+
params: Optional[dict] = None,
|
|
157
|
+
*,
|
|
158
|
+
socket_path: Optional[str] = None,
|
|
159
|
+
timeout: float = 8.0,
|
|
160
|
+
) -> Any:
|
|
161
|
+
path = socket_path or find_socket()
|
|
162
|
+
last_err: Optional[Exception] = None
|
|
163
|
+
data: Any = None
|
|
164
|
+
for attempt in range(3):
|
|
165
|
+
try:
|
|
166
|
+
data = _rpc_socket(method, params, path, timeout)
|
|
167
|
+
break
|
|
168
|
+
except (FileNotFoundError, ConnectionRefusedError, TimeoutError, OSError, CmuxError) as e:
|
|
169
|
+
last_err = e
|
|
170
|
+
if attempt == 1:
|
|
171
|
+
# Midway: try CLI once.
|
|
172
|
+
try:
|
|
173
|
+
data = _rpc_cli(method, params, path, timeout)
|
|
174
|
+
break
|
|
175
|
+
except Exception as cli_err:
|
|
176
|
+
last_err = cli_err
|
|
177
|
+
continue
|
|
178
|
+
if data is None:
|
|
179
|
+
raise CmuxError(str(last_err) if last_err else "cmux rpc failed")
|
|
180
|
+
|
|
181
|
+
if not isinstance(data, dict):
|
|
182
|
+
return data
|
|
183
|
+
|
|
184
|
+
# Error envelope (socket or CLI)
|
|
185
|
+
if data.get("ok") is False or (
|
|
186
|
+
data.get("error") and "result" not in data and "pong" not in data
|
|
187
|
+
):
|
|
188
|
+
err = data.get("error") or {}
|
|
189
|
+
if isinstance(err, dict):
|
|
190
|
+
msg = err.get("message") or err.get("code") or str(err)
|
|
191
|
+
else:
|
|
192
|
+
msg = str(err)
|
|
193
|
+
raise CmuxError(msg or "cmux error")
|
|
194
|
+
|
|
195
|
+
if "result" in data:
|
|
196
|
+
return data.get("result")
|
|
197
|
+
return data
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass
|
|
201
|
+
class Target:
|
|
202
|
+
workspace_id: str
|
|
203
|
+
workspace_ref: str
|
|
204
|
+
surface_id: str
|
|
205
|
+
surface_ref: str
|
|
206
|
+
window_id: Optional[str] = None
|
|
207
|
+
window_ref: Optional[str] = None
|
|
208
|
+
title: str = "cmux"
|
|
209
|
+
cwd: Optional[str] = None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@dataclass
|
|
213
|
+
class ChatMessage:
|
|
214
|
+
role: str # user | assistant | system
|
|
215
|
+
text: str
|
|
216
|
+
kind: str = "message" # message | command | system
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@dataclass
|
|
220
|
+
class Session:
|
|
221
|
+
"""Stateful bridge onto one cmux surface."""
|
|
222
|
+
|
|
223
|
+
target: Target
|
|
224
|
+
socket_path: str
|
|
225
|
+
_sent: list[str] = field(default_factory=list)
|
|
226
|
+
_last_text: str = ""
|
|
227
|
+
|
|
228
|
+
def refresh_target_meta(self) -> None:
|
|
229
|
+
# Prefer surface-level title from inventory. Workspace.list title is shared
|
|
230
|
+
# across tabs in the same workspace and would make every session look identical.
|
|
231
|
+
try:
|
|
232
|
+
for item in inventory(socket_path=self.socket_path):
|
|
233
|
+
if self.target.surface_ref in (
|
|
234
|
+
item.get("surface"),
|
|
235
|
+
item.get("surface_id"),
|
|
236
|
+
) or self.target.surface_id in (
|
|
237
|
+
item.get("surface"),
|
|
238
|
+
item.get("surface_id"),
|
|
239
|
+
):
|
|
240
|
+
if item.get("title"):
|
|
241
|
+
self.target.title = item["title"]
|
|
242
|
+
if item.get("cwd"):
|
|
243
|
+
self.target.cwd = item["cwd"]
|
|
244
|
+
if item.get("window"):
|
|
245
|
+
self.target.window_ref = item.get("window")
|
|
246
|
+
return
|
|
247
|
+
except CmuxError:
|
|
248
|
+
pass
|
|
249
|
+
workspaces = list_workspaces(socket_path=self.socket_path)
|
|
250
|
+
for ws in workspaces:
|
|
251
|
+
if ws["id"] == self.target.workspace_id or ws.get("ref") == self.target.workspace_ref:
|
|
252
|
+
if not self.target.title or self.target.title == "cmux":
|
|
253
|
+
self.target.title = (
|
|
254
|
+
ws.get("custom_title")
|
|
255
|
+
or ws.get("title")
|
|
256
|
+
or self.target.title
|
|
257
|
+
)
|
|
258
|
+
self.target.cwd = ws.get("current_directory") or self.target.cwd
|
|
259
|
+
break
|
|
260
|
+
|
|
261
|
+
def read_text(self, *, lines: int = 800, scrollback: bool = True) -> str:
|
|
262
|
+
result = rpc(
|
|
263
|
+
"surface.read_text",
|
|
264
|
+
{
|
|
265
|
+
"workspace_id": self.target.workspace_ref,
|
|
266
|
+
"surface_id": self.target.surface_ref,
|
|
267
|
+
"scrollback": scrollback,
|
|
268
|
+
"lines": lines,
|
|
269
|
+
},
|
|
270
|
+
socket_path=self.socket_path,
|
|
271
|
+
)
|
|
272
|
+
text = result.get("text")
|
|
273
|
+
if not text and result.get("base64"):
|
|
274
|
+
text = base64.b64decode(result["base64"]).decode("utf-8", "replace")
|
|
275
|
+
return text or ""
|
|
276
|
+
|
|
277
|
+
def interrupt(self) -> None:
|
|
278
|
+
"""Stop a running agent / command in the selected surface.
|
|
279
|
+
|
|
280
|
+
Agents disagree on the cancel key (Ctrl+C vs Escape), and some cmux
|
|
281
|
+
builds only honor one of send_key / send_text — so we fire all of them.
|
|
282
|
+
"""
|
|
283
|
+
self.focus()
|
|
284
|
+
base = {
|
|
285
|
+
"workspace_id": self.target.workspace_ref,
|
|
286
|
+
"surface_id": self.target.surface_ref,
|
|
287
|
+
}
|
|
288
|
+
attempts = [
|
|
289
|
+
("surface.send_key", {**base, "key": "c", "ctrl": True}),
|
|
290
|
+
("surface.send_text", {**base, "text": "\x03"}),
|
|
291
|
+
("surface.send_key", {**base, "key": "Escape"}),
|
|
292
|
+
]
|
|
293
|
+
ok = False
|
|
294
|
+
last_err: CmuxError | None = None
|
|
295
|
+
for method, params in attempts:
|
|
296
|
+
try:
|
|
297
|
+
rpc(method, params, socket_path=self.socket_path)
|
|
298
|
+
ok = True
|
|
299
|
+
except CmuxError as e:
|
|
300
|
+
last_err = e
|
|
301
|
+
if not ok and last_err is not None:
|
|
302
|
+
raise last_err
|
|
303
|
+
|
|
304
|
+
def focus(self) -> None:
|
|
305
|
+
"""Bring this surface to front so send/interrupt land in the visible tab."""
|
|
306
|
+
try:
|
|
307
|
+
rpc(
|
|
308
|
+
"surface.focus",
|
|
309
|
+
{
|
|
310
|
+
"workspace_id": self.target.workspace_ref,
|
|
311
|
+
"surface_id": self.target.surface_ref,
|
|
312
|
+
},
|
|
313
|
+
socket_path=self.socket_path,
|
|
314
|
+
)
|
|
315
|
+
except CmuxError:
|
|
316
|
+
pass
|
|
317
|
+
|
|
318
|
+
def send(self, text: str) -> None:
|
|
319
|
+
text = text.rstrip("\n")
|
|
320
|
+
if not text:
|
|
321
|
+
raise CmuxError("empty message")
|
|
322
|
+
self.focus()
|
|
323
|
+
# Agents expect a submit. Always end with Enter.
|
|
324
|
+
payload = text + "\n"
|
|
325
|
+
rpc(
|
|
326
|
+
"surface.send_text",
|
|
327
|
+
{
|
|
328
|
+
"workspace_id": self.target.workspace_ref,
|
|
329
|
+
"surface_id": self.target.surface_ref,
|
|
330
|
+
"text": payload,
|
|
331
|
+
},
|
|
332
|
+
socket_path=self.socket_path,
|
|
333
|
+
)
|
|
334
|
+
self._sent.append(text)
|
|
335
|
+
|
|
336
|
+
def transcript(self) -> list[ChatMessage]:
|
|
337
|
+
screen = self.read_text()
|
|
338
|
+
self._last_text = screen
|
|
339
|
+
parsed = parse_screen(screen)
|
|
340
|
+
# Ensure phone-originated sends show up even if the agent UI ate the echo.
|
|
341
|
+
return merge_sent(parsed, self._sent)
|
|
342
|
+
|
|
343
|
+
def busy(self) -> bool:
|
|
344
|
+
return is_busy(self._last_text or self.read_text())
|
|
345
|
+
|
|
346
|
+
def notify_linked(self) -> None:
|
|
347
|
+
try:
|
|
348
|
+
rpc(
|
|
349
|
+
"notification.create",
|
|
350
|
+
{
|
|
351
|
+
"title": "Call Walkie Talkie",
|
|
352
|
+
"body": "Phone linked — chatting into this workspace",
|
|
353
|
+
"workspace_id": self.target.workspace_ref,
|
|
354
|
+
"surface_id": self.target.surface_ref,
|
|
355
|
+
},
|
|
356
|
+
socket_path=self.socket_path,
|
|
357
|
+
)
|
|
358
|
+
except CmuxError:
|
|
359
|
+
pass
|
|
360
|
+
try:
|
|
361
|
+
# Best-effort sidebar presence. Method names vary across cmux builds.
|
|
362
|
+
rpc(
|
|
363
|
+
"notification.create",
|
|
364
|
+
{
|
|
365
|
+
"title": "Call Walkie Talkie · live",
|
|
366
|
+
"body": self.target.title,
|
|
367
|
+
},
|
|
368
|
+
socket_path=self.socket_path,
|
|
369
|
+
)
|
|
370
|
+
except CmuxError:
|
|
371
|
+
pass
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# ---------------------------------------------------------------- discovery
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def ping(socket_path: Optional[str] = None) -> dict:
|
|
378
|
+
path = socket_path or find_socket()
|
|
379
|
+
result = rpc("system.ping", socket_path=path)
|
|
380
|
+
return {"ok": True, "socket_path": path, "pong": bool((result or {}).get("pong", True))}
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def identify(socket_path: Optional[str] = None) -> dict:
|
|
384
|
+
return rpc("system.identify", socket_path=socket_path or find_socket())
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def list_workspaces(socket_path: Optional[str] = None) -> list[dict]:
|
|
388
|
+
result = rpc("workspace.list", socket_path=socket_path or find_socket())
|
|
389
|
+
return list((result or {}).get("workspaces") or [])
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def list_surfaces(workspace_ref: str, socket_path: Optional[str] = None) -> list[dict]:
|
|
393
|
+
result = rpc(
|
|
394
|
+
"surface.list",
|
|
395
|
+
{"workspace_id": workspace_ref},
|
|
396
|
+
socket_path=socket_path or find_socket(),
|
|
397
|
+
)
|
|
398
|
+
return list((result or {}).get("surfaces") or [])
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def inventory(socket_path: Optional[str] = None) -> list[dict]:
|
|
402
|
+
"""Flat list of pickable terminal sessions across windows/workspaces."""
|
|
403
|
+
path = socket_path or find_socket()
|
|
404
|
+
items: list[dict] = []
|
|
405
|
+
tree = None
|
|
406
|
+
try:
|
|
407
|
+
tree = rpc("system.tree", socket_path=path)
|
|
408
|
+
except CmuxError:
|
|
409
|
+
try:
|
|
410
|
+
tree = rpc("tree", socket_path=path)
|
|
411
|
+
except CmuxError:
|
|
412
|
+
tree = None
|
|
413
|
+
|
|
414
|
+
if isinstance(tree, dict) and tree.get("windows"):
|
|
415
|
+
for win in tree.get("windows") or []:
|
|
416
|
+
win_ref = win.get("ref") or win.get("window_ref")
|
|
417
|
+
for ws in win.get("workspaces") or []:
|
|
418
|
+
ws_ref = ws.get("ref") or (f"workspace:{ws['index']}" if "index" in ws else None)
|
|
419
|
+
ws_id = ws.get("id")
|
|
420
|
+
ws_title = ws.get("custom_title") or ws.get("title") or ws_ref
|
|
421
|
+
ws_cwd = ws.get("current_directory")
|
|
422
|
+
for pane in ws.get("panes") or []:
|
|
423
|
+
for surf in pane.get("surfaces") or []:
|
|
424
|
+
if (surf.get("type") or "terminal") != "terminal":
|
|
425
|
+
continue
|
|
426
|
+
items.append(
|
|
427
|
+
{
|
|
428
|
+
"window": win_ref,
|
|
429
|
+
"workspace": ws_ref or ws_id,
|
|
430
|
+
"workspace_id": ws_id,
|
|
431
|
+
"surface": surf.get("ref") or surf.get("id"),
|
|
432
|
+
"surface_id": surf.get("id"),
|
|
433
|
+
"title": surf.get("title") or ws_title or "terminal",
|
|
434
|
+
"cwd": ws_cwd,
|
|
435
|
+
"active": bool(surf.get("active") or surf.get("focused")),
|
|
436
|
+
}
|
|
437
|
+
)
|
|
438
|
+
if items:
|
|
439
|
+
return items
|
|
440
|
+
|
|
441
|
+
# Fallback: workspace.list + surface.list
|
|
442
|
+
for ws in list_workspaces(socket_path=path):
|
|
443
|
+
ws_ref = ws.get("ref") or ws.get("id")
|
|
444
|
+
for surf in list_surfaces(ws_ref, socket_path=path):
|
|
445
|
+
if (surf.get("type") or "terminal") != "terminal":
|
|
446
|
+
continue
|
|
447
|
+
items.append(
|
|
448
|
+
{
|
|
449
|
+
"window": None,
|
|
450
|
+
"workspace": ws_ref,
|
|
451
|
+
"workspace_id": ws.get("id"),
|
|
452
|
+
"surface": surf.get("ref") or surf.get("id"),
|
|
453
|
+
"surface_id": surf.get("id"),
|
|
454
|
+
"title": surf.get("title")
|
|
455
|
+
or ws.get("custom_title")
|
|
456
|
+
or ws.get("title")
|
|
457
|
+
or "terminal",
|
|
458
|
+
"cwd": ws.get("current_directory"),
|
|
459
|
+
"active": bool(surf.get("focused") or surf.get("selected_in_pane")),
|
|
460
|
+
}
|
|
461
|
+
)
|
|
462
|
+
return items
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def resolve_target(
|
|
466
|
+
*,
|
|
467
|
+
workspace: Optional[str] = None,
|
|
468
|
+
surface: Optional[str] = None,
|
|
469
|
+
window: Optional[str] = None,
|
|
470
|
+
socket_path: Optional[str] = None,
|
|
471
|
+
) -> Target:
|
|
472
|
+
path = socket_path or find_socket()
|
|
473
|
+
inv = inventory(socket_path=path)
|
|
474
|
+
ident = identify(socket_path=path)
|
|
475
|
+
focused = (ident or {}).get("focused") or {}
|
|
476
|
+
|
|
477
|
+
pick = None
|
|
478
|
+
if surface or workspace:
|
|
479
|
+
for item in inv:
|
|
480
|
+
if surface and surface not in (
|
|
481
|
+
item.get("surface"),
|
|
482
|
+
item.get("surface_id"),
|
|
483
|
+
):
|
|
484
|
+
continue
|
|
485
|
+
if workspace and workspace not in (
|
|
486
|
+
item.get("workspace"),
|
|
487
|
+
item.get("workspace_id"),
|
|
488
|
+
):
|
|
489
|
+
continue
|
|
490
|
+
if window and window not in (item.get("window"),):
|
|
491
|
+
continue
|
|
492
|
+
pick = item
|
|
493
|
+
break
|
|
494
|
+
if pick is None:
|
|
495
|
+
for item in inv:
|
|
496
|
+
if item.get("active"):
|
|
497
|
+
pick = item
|
|
498
|
+
break
|
|
499
|
+
if pick is None and inv:
|
|
500
|
+
pick = inv[0]
|
|
501
|
+
if pick is None:
|
|
502
|
+
# Last resort: focused refs from identify
|
|
503
|
+
if not focused.get("surface_ref") and not focused.get("surface_id"):
|
|
504
|
+
raise CmuxError("No cmux terminal open. Create one in cmux, then retry.")
|
|
505
|
+
pick = {
|
|
506
|
+
"workspace": focused.get("workspace_ref") or focused.get("workspace_id"),
|
|
507
|
+
"workspace_id": focused.get("workspace_id"),
|
|
508
|
+
"surface": focused.get("surface_ref") or focused.get("surface_id"),
|
|
509
|
+
"surface_id": focused.get("surface_id"),
|
|
510
|
+
"window": focused.get("window_ref"),
|
|
511
|
+
"title": "cmux",
|
|
512
|
+
"cwd": None,
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
# Enrich title/cwd from workspace.list when tree omitted them
|
|
516
|
+
if not pick.get("cwd") or pick.get("title") in (None, "terminal", "cmux"):
|
|
517
|
+
for ws in list_workspaces(socket_path=path):
|
|
518
|
+
if pick.get("workspace") in (ws.get("ref"), ws.get("id")):
|
|
519
|
+
pick["cwd"] = pick.get("cwd") or ws.get("current_directory")
|
|
520
|
+
if pick.get("title") in (None, "terminal", "cmux"):
|
|
521
|
+
pick["title"] = ws.get("custom_title") or ws.get("title") or pick.get("title")
|
|
522
|
+
break
|
|
523
|
+
|
|
524
|
+
return Target(
|
|
525
|
+
workspace_id=pick.get("workspace_id") or pick.get("workspace"),
|
|
526
|
+
workspace_ref=pick.get("workspace"),
|
|
527
|
+
surface_id=pick.get("surface_id") or pick.get("surface"),
|
|
528
|
+
surface_ref=pick.get("surface"),
|
|
529
|
+
window_id=focused.get("window_id"),
|
|
530
|
+
window_ref=pick.get("window") or focused.get("window_ref"),
|
|
531
|
+
title=pick.get("title") or "cmux",
|
|
532
|
+
cwd=pick.get("cwd"),
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def open_session(
|
|
537
|
+
*,
|
|
538
|
+
workspace: Optional[str] = None,
|
|
539
|
+
surface: Optional[str] = None,
|
|
540
|
+
window: Optional[str] = None,
|
|
541
|
+
) -> Session:
|
|
542
|
+
path = find_socket()
|
|
543
|
+
target = resolve_target(
|
|
544
|
+
workspace=workspace, surface=surface, window=window, socket_path=path
|
|
545
|
+
)
|
|
546
|
+
return Session(target=target, socket_path=path)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
# ---------------------------------------------------------------- screen → messages
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
_BOXY = re.compile(r"[╭╮╯╰│─┌┐└┘▄▀━┃╌╍╎╏╔╗╚╝║═]")
|
|
553
|
+
_CMD_START = re.compile(r"^\s*\$\s+(.*)$")
|
|
554
|
+
_SHELL_PROMPT = re.compile(
|
|
555
|
+
r"^[^\n]*[@%][^\n]*[ %#❯] |^[^\n]*[ %#❯]\s*$"
|
|
556
|
+
)
|
|
557
|
+
_HIDDEN = re.compile(r"^\s*…\s+\d+.*(hidden|expand)", re.I)
|
|
558
|
+
_STATUSY = re.compile(
|
|
559
|
+
r"(Running|Waiting|tokens|Goal active|Run Everything|ctrl\+[co]|Auto ·)",
|
|
560
|
+
re.I,
|
|
561
|
+
)
|
|
562
|
+
_BRAILLE = re.compile(r"[⠁-⣿⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]")
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def is_busy(screen: str) -> bool:
|
|
566
|
+
"""True when the agent UI looks mid-run (stoppable with Ctrl+C / Esc)."""
|
|
567
|
+
lines = [ln.strip() for ln in (screen or "").splitlines() if ln.strip()]
|
|
568
|
+
for s in lines[-40:]:
|
|
569
|
+
low = s.lower()
|
|
570
|
+
# Shell / CLI echo — not an agent run (e.g. "Already running.").
|
|
571
|
+
if low.startswith("already running"):
|
|
572
|
+
continue
|
|
573
|
+
# Explicit stop affordance from agent UIs.
|
|
574
|
+
if "ctrl+c" in low and (
|
|
575
|
+
"stop" in low or s.lstrip().startswith("→") or "press" in low
|
|
576
|
+
):
|
|
577
|
+
return True
|
|
578
|
+
# Progress lines: "Running 2.83k tokens" / spinner + running|waiting|…
|
|
579
|
+
if re.search(r"\brunning\b", low) and (
|
|
580
|
+
"token" in low or _BRAILLE.search(s) or "…" in s or "..." in s
|
|
581
|
+
):
|
|
582
|
+
return True
|
|
583
|
+
if _BRAILLE.search(s) and re.search(
|
|
584
|
+
r"\b(running|waiting|thinking|generating)\b", low
|
|
585
|
+
):
|
|
586
|
+
return True
|
|
587
|
+
if re.search(r"\bwaiting\b", low) and (
|
|
588
|
+
"token" in low or "model" in low or "response" in low or "tool" in low
|
|
589
|
+
):
|
|
590
|
+
return True
|
|
591
|
+
return False
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _is_chrome(line: str) -> bool:
|
|
595
|
+
s = line.strip()
|
|
596
|
+
if not s:
|
|
597
|
+
return True
|
|
598
|
+
if _BOXY.search(s) and len(_BOXY.findall(s)) >= 3:
|
|
599
|
+
return True
|
|
600
|
+
if s.startswith(("→ ", "→")) and ("ctrl+" in s.lower() or "stop" in s.lower()):
|
|
601
|
+
return True
|
|
602
|
+
if s.startswith("Auto ·") or "Run Everything" in s:
|
|
603
|
+
return True
|
|
604
|
+
if _STATUSY.search(s) and len(s) < 140:
|
|
605
|
+
# Status footers / progress — skip as chat content.
|
|
606
|
+
if "tokens" in s.lower() or "goal active" in s.lower() or s.startswith(("⠠", "⠰", "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")):
|
|
607
|
+
return True
|
|
608
|
+
if re.match(r"^[⠁-⣿\s]+", s):
|
|
609
|
+
return True
|
|
610
|
+
if s in {"1 task", "Press Ctrl+C again to exit"}:
|
|
611
|
+
return True
|
|
612
|
+
if s.startswith("Tip:") and len(s) < 100:
|
|
613
|
+
return True
|
|
614
|
+
if re.match(r"^~?/.*·\s*(main|master|develop)", s):
|
|
615
|
+
return True
|
|
616
|
+
return False
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _is_shell_echo(line: str) -> bool:
|
|
620
|
+
# Classic zsh/bash prompt lines the user typed locally — treat as command.
|
|
621
|
+
if re.search(r"%\s+\S+", line) and ("@" in line or "Documents" in line or "Projects" in line or "~" in line):
|
|
622
|
+
# `user@host dir % cmd`
|
|
623
|
+
if re.search(r"[%$#]\s+\S+", line):
|
|
624
|
+
return True
|
|
625
|
+
return False
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
_USER_META = re.compile(
|
|
629
|
+
r"^(Read\b|Grepped\b|Edited\b|Monitored\b|Ran\b|Waited\b|Finished\b|Found \d|SyntaxError|File \"|<stdin>)",
|
|
630
|
+
re.I,
|
|
631
|
+
)
|
|
632
|
+
_TOOL_START = re.compile(
|
|
633
|
+
r"^(Read\b|Read,|Grepped\b|Edited\b|Monitored\b|Ran\b|Waited\b|Finished\b)",
|
|
634
|
+
re.I,
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _tool_line_text(line: str) -> str:
|
|
639
|
+
if line.startswith(" ") and not line.startswith(" "):
|
|
640
|
+
return _strip_assistant_line(line)
|
|
641
|
+
return line.rstrip()
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _is_tool_start_line(line: str) -> bool:
|
|
645
|
+
if not line.strip() or _CMD_START.match(line):
|
|
646
|
+
return False
|
|
647
|
+
return bool(_TOOL_START.match(_tool_line_text(line).strip()))
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _is_tool_block_continuation(line: str) -> bool:
|
|
651
|
+
if not line.strip():
|
|
652
|
+
return True
|
|
653
|
+
if _HIDDEN.match(line):
|
|
654
|
+
return True
|
|
655
|
+
if line.startswith(" ") or line.startswith("\t"):
|
|
656
|
+
return True
|
|
657
|
+
stripped = line.strip()
|
|
658
|
+
if stripped.startswith("▎"):
|
|
659
|
+
return True
|
|
660
|
+
if _is_tool_start_line(line):
|
|
661
|
+
return True
|
|
662
|
+
if re.match(r"^Found \d+\s+match", stripped, re.I):
|
|
663
|
+
return True
|
|
664
|
+
if line.startswith(" ") and not line.startswith(" "):
|
|
665
|
+
inner = _strip_assistant_line(line)
|
|
666
|
+
if _TOOL_START.match(inner):
|
|
667
|
+
return True
|
|
668
|
+
if inner.startswith("…"):
|
|
669
|
+
return True
|
|
670
|
+
if re.search(r"\.(html|py|js|tsx|ts|json|md|mjs|css|txt)\b", inner, re.I):
|
|
671
|
+
return True
|
|
672
|
+
return False
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _split_message_tools(text: str) -> list[ChatMessage]:
|
|
676
|
+
"""Pull Read/Grep/Edit/Monitored blocks out of assistant prose."""
|
|
677
|
+
lines = text.splitlines()
|
|
678
|
+
out: list[ChatMessage] = []
|
|
679
|
+
abuf: list[str] = []
|
|
680
|
+
i = 0
|
|
681
|
+
while i < len(lines):
|
|
682
|
+
line = lines[i]
|
|
683
|
+
if _is_tool_start_line(line):
|
|
684
|
+
if abuf:
|
|
685
|
+
body = "\n".join(abuf).strip()
|
|
686
|
+
abuf = []
|
|
687
|
+
if body:
|
|
688
|
+
out.append(ChatMessage(role="assistant", text=body, kind="message"))
|
|
689
|
+
block = [_tool_line_text(line)]
|
|
690
|
+
i += 1
|
|
691
|
+
while i < len(lines) and _is_tool_block_continuation(lines[i]):
|
|
692
|
+
if lines[i].strip():
|
|
693
|
+
block.append(lines[i].rstrip())
|
|
694
|
+
i += 1
|
|
695
|
+
body = "\n".join(block).strip()
|
|
696
|
+
if body:
|
|
697
|
+
out.append(ChatMessage(role="assistant", text=body, kind="command"))
|
|
698
|
+
continue
|
|
699
|
+
if line.strip():
|
|
700
|
+
abuf.append(_strip_assistant_line(line))
|
|
701
|
+
i += 1
|
|
702
|
+
if abuf:
|
|
703
|
+
body = "\n".join(abuf).strip()
|
|
704
|
+
if body:
|
|
705
|
+
out.append(ChatMessage(role="assistant", text=body, kind="message"))
|
|
706
|
+
return out
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _split_tool_messages(msgs: list[ChatMessage]) -> list[ChatMessage]:
|
|
710
|
+
out: list[ChatMessage] = []
|
|
711
|
+
for m in msgs:
|
|
712
|
+
if m.role != "assistant" or m.kind != "message":
|
|
713
|
+
out.append(m)
|
|
714
|
+
continue
|
|
715
|
+
out.extend(_split_message_tools(m.text))
|
|
716
|
+
return out
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _norm_chat_text(text: str) -> str:
|
|
720
|
+
return re.sub(r"\s+", " ", (text or "").strip())
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _is_user_padding_only(line: str) -> bool:
|
|
724
|
+
return bool(line.strip() == "" and len(line) >= 20)
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _is_user_input(line: str) -> bool:
|
|
728
|
+
"""cmux agent UI right-aligns user turns with heavy trailing padding."""
|
|
729
|
+
if not line.strip() or _CMD_START.match(line) or _is_chrome(line):
|
|
730
|
+
return False
|
|
731
|
+
stripped = line.strip()
|
|
732
|
+
if stripped.startswith("▎") or "ctrl+o" in stripped or "ctrl+r" in stripped:
|
|
733
|
+
return False
|
|
734
|
+
if _HIDDEN.match(line) or _USER_META.match(stripped):
|
|
735
|
+
return False
|
|
736
|
+
lead = len(line) - len(line.lstrip(" "))
|
|
737
|
+
trail = len(line) - len(line.rstrip(" "))
|
|
738
|
+
pad = lead + trail
|
|
739
|
+
if lead > 4:
|
|
740
|
+
return False
|
|
741
|
+
if trail >= 10:
|
|
742
|
+
return True
|
|
743
|
+
# Wrapped user turns still sit in the ~54-col right-aligned slot.
|
|
744
|
+
if len(line) >= 48 and pad >= 18 and len(stripped) <= 120:
|
|
745
|
+
return True
|
|
746
|
+
return False
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _is_user_continuation(line: str) -> bool:
|
|
750
|
+
"""Next line of a wrapped / pasted user turn (paths, lowercase, etc.)."""
|
|
751
|
+
if not line.strip() or _CMD_START.match(line) or _is_chrome(line):
|
|
752
|
+
return False
|
|
753
|
+
stripped = line.strip()
|
|
754
|
+
if stripped.startswith("▎") or _HIDDEN.match(line) or _USER_META.match(stripped):
|
|
755
|
+
return False
|
|
756
|
+
lead = len(line) - len(line.lstrip(" "))
|
|
757
|
+
if lead > 4:
|
|
758
|
+
return False
|
|
759
|
+
if _is_user_input(line):
|
|
760
|
+
return True
|
|
761
|
+
if stripped.startswith(("/", ".", "~")) or "\\" in stripped or "/" in stripped:
|
|
762
|
+
return True
|
|
763
|
+
if any(tok in stripped for tok in (".png", ".jpg", "Screenshot", "TemporaryItems")):
|
|
764
|
+
return True
|
|
765
|
+
if stripped and stripped[0].islower():
|
|
766
|
+
return True
|
|
767
|
+
trail = len(line) - len(line.rstrip(" "))
|
|
768
|
+
if trail >= 8:
|
|
769
|
+
return True
|
|
770
|
+
return False
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _strip_assistant_line(line: str) -> str:
|
|
774
|
+
if line.startswith(" ") and not line.startswith(" "):
|
|
775
|
+
return line[2:].rstrip()
|
|
776
|
+
return line.rstrip()
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def parse_screen(text: str) -> list[ChatMessage]:
|
|
780
|
+
"""Turn a cmux terminal dump into chat + command blocks."""
|
|
781
|
+
out: list[ChatMessage] = []
|
|
782
|
+
abuf: list[str] = []
|
|
783
|
+
ubuf: list[str] = []
|
|
784
|
+
|
|
785
|
+
def flush_assistant():
|
|
786
|
+
nonlocal abuf
|
|
787
|
+
body = "\n".join(abuf).strip()
|
|
788
|
+
abuf = []
|
|
789
|
+
if body:
|
|
790
|
+
out.append(ChatMessage(role="assistant", text=body, kind="message"))
|
|
791
|
+
|
|
792
|
+
def flush_user():
|
|
793
|
+
nonlocal ubuf
|
|
794
|
+
body = " ".join(p.strip() for p in ubuf if p.strip()).strip()
|
|
795
|
+
ubuf = []
|
|
796
|
+
if body:
|
|
797
|
+
out.append(ChatMessage(role="user", text=body, kind="message"))
|
|
798
|
+
|
|
799
|
+
def flush_all():
|
|
800
|
+
flush_user()
|
|
801
|
+
flush_assistant()
|
|
802
|
+
|
|
803
|
+
def flush_command(lines: list[str]):
|
|
804
|
+
body = "\n".join(lines).strip()
|
|
805
|
+
if body:
|
|
806
|
+
out.append(ChatMessage(role="assistant", text=body, kind="command"))
|
|
807
|
+
|
|
808
|
+
lines = text.splitlines()
|
|
809
|
+
i = 0
|
|
810
|
+
while i < len(lines):
|
|
811
|
+
line = lines[i]
|
|
812
|
+
if _is_chrome(line):
|
|
813
|
+
flush_all()
|
|
814
|
+
i += 1
|
|
815
|
+
continue
|
|
816
|
+
|
|
817
|
+
m = _CMD_START.match(line)
|
|
818
|
+
if m:
|
|
819
|
+
flush_all()
|
|
820
|
+
cmd = [m.group(1).rstrip()]
|
|
821
|
+
i += 1
|
|
822
|
+
while i < len(lines):
|
|
823
|
+
nxt = lines[i]
|
|
824
|
+
if _CMD_START.match(nxt) or _is_chrome(nxt):
|
|
825
|
+
break
|
|
826
|
+
if _HIDDEN.match(nxt):
|
|
827
|
+
i += 1
|
|
828
|
+
continue
|
|
829
|
+
# Indented continuation / tool output belongs to the command.
|
|
830
|
+
if nxt.startswith(" ") or nxt.startswith("\t") or not nxt.strip():
|
|
831
|
+
if nxt.strip():
|
|
832
|
+
cmd.append(nxt.rstrip())
|
|
833
|
+
i += 1
|
|
834
|
+
continue
|
|
835
|
+
# Bare output still part of command until a prose break.
|
|
836
|
+
if nxt.startswith(" ") and not nxt.startswith(" $"):
|
|
837
|
+
cmd.append(nxt.rstrip())
|
|
838
|
+
i += 1
|
|
839
|
+
continue
|
|
840
|
+
break
|
|
841
|
+
flush_command(cmd)
|
|
842
|
+
continue
|
|
843
|
+
|
|
844
|
+
if _is_shell_echo(line):
|
|
845
|
+
flush_all()
|
|
846
|
+
# Take the part after the final prompt marker.
|
|
847
|
+
parts = re.split(r"[%$#❯]\s+", line, maxsplit=1)
|
|
848
|
+
cmd_text = parts[-1].strip() if len(parts) > 1 else line.strip()
|
|
849
|
+
if cmd_text:
|
|
850
|
+
flush_command([cmd_text])
|
|
851
|
+
i += 1
|
|
852
|
+
continue
|
|
853
|
+
|
|
854
|
+
if _HIDDEN.match(line):
|
|
855
|
+
i += 1
|
|
856
|
+
continue
|
|
857
|
+
|
|
858
|
+
if _is_user_padding_only(line):
|
|
859
|
+
flush_user()
|
|
860
|
+
i += 1
|
|
861
|
+
continue
|
|
862
|
+
|
|
863
|
+
if ubuf and _is_user_continuation(line):
|
|
864
|
+
ubuf.append(line.strip())
|
|
865
|
+
i += 1
|
|
866
|
+
continue
|
|
867
|
+
|
|
868
|
+
if _is_user_input(line):
|
|
869
|
+
flush_assistant()
|
|
870
|
+
ubuf.append(line.strip())
|
|
871
|
+
i += 1
|
|
872
|
+
continue
|
|
873
|
+
|
|
874
|
+
if _is_tool_start_line(line):
|
|
875
|
+
flush_user()
|
|
876
|
+
flush_assistant()
|
|
877
|
+
tool = [_tool_line_text(line)]
|
|
878
|
+
i += 1
|
|
879
|
+
while i < len(lines) and _is_tool_block_continuation(lines[i]):
|
|
880
|
+
if lines[i].strip():
|
|
881
|
+
tool.append(lines[i].rstrip())
|
|
882
|
+
i += 1
|
|
883
|
+
flush_command(tool)
|
|
884
|
+
continue
|
|
885
|
+
|
|
886
|
+
flush_user()
|
|
887
|
+
abuf.append(_strip_assistant_line(line))
|
|
888
|
+
i += 1
|
|
889
|
+
|
|
890
|
+
flush_all()
|
|
891
|
+
return _split_tool_messages(_resplit_assistant_blobs(_coalesce(out)))
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def _coalesce(msgs: list[ChatMessage]) -> list[ChatMessage]:
|
|
895
|
+
if not msgs:
|
|
896
|
+
return msgs
|
|
897
|
+
merged: list[ChatMessage] = [msgs[0]]
|
|
898
|
+
for m in msgs[1:]:
|
|
899
|
+
prev = merged[-1]
|
|
900
|
+
if prev.kind == m.kind == "message" and prev.role == m.role:
|
|
901
|
+
prev.text = (prev.text + "\n" + m.text).strip()
|
|
902
|
+
else:
|
|
903
|
+
merged.append(m)
|
|
904
|
+
# Drop tiny noise crumbs
|
|
905
|
+
return [m for m in merged if len(m.text.strip()) >= 2]
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def _resplit_assistant_blobs(msgs: list[ChatMessage]) -> list[ChatMessage]:
|
|
909
|
+
"""Recover user turns that were previously merged into assistant prose."""
|
|
910
|
+
out: list[ChatMessage] = []
|
|
911
|
+
for m in msgs:
|
|
912
|
+
if m.role != "assistant" or m.kind != "message" or "\n" not in m.text:
|
|
913
|
+
out.append(m)
|
|
914
|
+
continue
|
|
915
|
+
abuf: list[str] = []
|
|
916
|
+
ubuf: list[str] = []
|
|
917
|
+
|
|
918
|
+
def flush_a():
|
|
919
|
+
nonlocal abuf
|
|
920
|
+
body = "\n".join(abuf).strip()
|
|
921
|
+
abuf = []
|
|
922
|
+
if body:
|
|
923
|
+
out.append(ChatMessage(role="assistant", text=body, kind="message"))
|
|
924
|
+
|
|
925
|
+
def flush_u():
|
|
926
|
+
nonlocal ubuf
|
|
927
|
+
body = " ".join(p.strip() for p in ubuf if p.strip()).strip()
|
|
928
|
+
ubuf = []
|
|
929
|
+
if body:
|
|
930
|
+
out.append(ChatMessage(role="user", text=body, kind="message"))
|
|
931
|
+
|
|
932
|
+
for line in m.text.splitlines():
|
|
933
|
+
if _is_user_padding_only(line):
|
|
934
|
+
flush_u()
|
|
935
|
+
continue
|
|
936
|
+
if ubuf and _is_user_continuation(line):
|
|
937
|
+
ubuf.append(line.strip())
|
|
938
|
+
continue
|
|
939
|
+
if _is_user_input(line):
|
|
940
|
+
flush_a()
|
|
941
|
+
ubuf.append(line.strip())
|
|
942
|
+
continue
|
|
943
|
+
flush_u()
|
|
944
|
+
abuf.append(_strip_assistant_line(line))
|
|
945
|
+
flush_u()
|
|
946
|
+
flush_a()
|
|
947
|
+
return out or msgs
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def _dedupe_user_messages(msgs: list[ChatMessage]) -> list[ChatMessage]:
|
|
951
|
+
drop: set[int] = set()
|
|
952
|
+
user_idxs = [i for i, m in enumerate(msgs) if m.role == "user"]
|
|
953
|
+
norms = [_norm_chat_text(msgs[i].text) for i in user_idxs]
|
|
954
|
+
for ai, ni in enumerate(norms):
|
|
955
|
+
for bi, nj in enumerate(norms):
|
|
956
|
+
if ai == bi or not ni or not nj or ni == nj:
|
|
957
|
+
continue
|
|
958
|
+
if len(ni) < len(nj) and ni in nj:
|
|
959
|
+
drop.add(user_idxs[ai])
|
|
960
|
+
elif len(nj) < len(ni) and nj in ni:
|
|
961
|
+
drop.add(user_idxs[bi])
|
|
962
|
+
return [m for i, m in enumerate(msgs) if i not in drop]
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def merge_sent(parsed: list[ChatMessage], sent: list[str]) -> list[ChatMessage]:
|
|
966
|
+
"""Append phone sends; drop fragment crumbs the screen parser split out."""
|
|
967
|
+
if not sent:
|
|
968
|
+
return _dedupe_user_messages(parsed)
|
|
969
|
+
sent_norms = [_norm_chat_text(t) for t in sent if _norm_chat_text(t)]
|
|
970
|
+
sent_set = set(sent_norms)
|
|
971
|
+
|
|
972
|
+
def is_user_crumb(text: str) -> bool:
|
|
973
|
+
n = _norm_chat_text(text)
|
|
974
|
+
if not n or n in sent_set:
|
|
975
|
+
return False
|
|
976
|
+
return any(len(n) >= 3 and n in sn for sn in sent_norms)
|
|
977
|
+
|
|
978
|
+
base = [m for m in parsed if not (m.role == "user" and is_user_crumb(m.text))]
|
|
979
|
+
existing = {_norm_chat_text(m.text) for m in base if m.role == "user"}
|
|
980
|
+
extras = [
|
|
981
|
+
ChatMessage(role="user", text=t, kind="message")
|
|
982
|
+
for t in sent
|
|
983
|
+
if _norm_chat_text(t) not in existing
|
|
984
|
+
]
|
|
985
|
+
return _dedupe_user_messages(base + extras)
|