pairling 0.0.1 → 0.1.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/package.json +5 -1
- package/payload/mac/SOURCE_BRANCH +1 -0
- package/payload/mac/SOURCE_DIRTY +1 -0
- package/payload/mac/SOURCE_REVISION +1 -0
- package/payload/mac/VERSION +1 -0
- package/payload/mac/companiond/integrations/__init__.py +1 -0
- package/payload/mac/companiond/integrations/aperture_cli/__init__.py +23 -0
- package/payload/mac/companiond/integrations/aperture_cli/launch.py +456 -0
- package/payload/mac/companiond/integrations/aperture_cli/status.py +393 -0
- package/payload/mac/companiond/live_activity_publisher.py +380 -0
- package/payload/mac/companiond/llm_route.py +108 -0
- package/payload/mac/companiond/local_mcp_bridge.py +156 -0
- package/payload/mac/companiond/model_status_contract.py +101 -0
- package/payload/mac/companiond/pairdrop_store.py +920 -0
- package/payload/mac/companiond/pairling_connectd_status.py +149 -0
- package/payload/mac/companiond/pairling_devices.py +459 -0
- package/payload/mac/companiond/pairling_pairing.py +404 -0
- package/payload/mac/companiond/pairling_relay_claims.py +232 -0
- package/payload/mac/companiond/pairling_tools.py +706 -0
- package/payload/mac/companiond/pairlingd.py +18438 -0
- package/payload/mac/companiond/providers/__init__.py +1 -0
- package/payload/mac/companiond/providers/base.py +255 -0
- package/payload/mac/companiond/providers/claude.py +127 -0
- package/payload/mac/companiond/providers/codex.py +124 -0
- package/payload/mac/companiond/providers/external.py +46 -0
- package/payload/mac/companiond/providers/registry.py +70 -0
- package/payload/mac/companiond/pty_broker.py +887 -0
- package/payload/mac/companiond/push_dispatcher.py +1990 -0
- package/payload/mac/companiond/push_event_catalog.py +566 -0
- package/payload/mac/companiond/request_proof.py +142 -0
- package/payload/mac/companiond/runtime_contract.py +47 -0
- package/payload/mac/companiond/runtime_manifest.py +197 -0
- package/payload/mac/companiond/runtime_paths.py +87 -0
- package/payload/mac/companiond/safety_monitor.py +542 -0
- package/payload/mac/companiond/sentinel_notifications.py +491 -0
- package/payload/mac/companiond/standard_push_publisher.py +516 -0
- package/payload/mac/companiond/substrate_status_contract.py +139 -0
- package/payload/mac/companiond/terminal_screen_backend.py +332 -0
- package/payload/mac/companiond/terminal_text_sanitizer.py +54 -0
- package/payload/mac/companiond/workstate_feed_contract.py +108 -0
- package/payload/mac/connectd/cmd/pairling-connectd/auth_open_test.go +116 -0
- package/payload/mac/connectd/cmd/pairling-connectd/main.go +345 -0
- package/payload/mac/connectd/cmd/pairling-connectd/upstream_health_test.go +33 -0
- package/payload/mac/connectd/go.mod +51 -0
- package/payload/mac/connectd/go.sum +229 -0
- package/payload/mac/connectd/internal/gateway/proxy.go +597 -0
- package/payload/mac/connectd/internal/gateway/proxy_test.go +531 -0
- package/payload/mac/connectd/internal/runtime/config.go +99 -0
- package/payload/mac/connectd/internal/runtime/config_test.go +29 -0
- package/payload/mac/connectd/internal/status/status.go +300 -0
- package/payload/mac/connectd/internal/status/status_test.go +263 -0
- package/payload/mac/guardian/companion-power-guardian.py +613 -0
- package/payload/mac/guardian/guardian_contract.py +67 -0
- package/payload/mac/install/bootstrap-first-run.sh +206 -0
- package/payload/mac/install/doctor.sh +660 -0
- package/payload/mac/install/install-runtime.sh +1241 -0
- package/payload/mac/install/render-launchd.py +119 -0
- package/payload/mac/install/uninstall-runtime.sh +136 -0
- package/payload/mac/mcp/phone_tools.py +210 -0
- package/payload/mac/packaging/bin/pairling +63 -0
- package/payload-manifest.json +255 -0
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Daemon-side router for Pairling MCP tools."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import socket
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Callable
|
|
17
|
+
|
|
18
|
+
from llm_route import LLMRouteError, run_local_llm
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
SCHEMA_VERSION = 1
|
|
22
|
+
ALLOWED_TOOLS = {"vibe_check", "second_opinion", "user_likely_prefers", "corpus_recall"}
|
|
23
|
+
ALLOWED_STRATEGIES = {"auto", "iphone_only", "mac_only"}
|
|
24
|
+
PHONE_TOOL_LIST = sorted(ALLOWED_TOOLS)
|
|
25
|
+
MAX_INPUT_CHARS = 12_000
|
|
26
|
+
MAX_OUTPUT_CHARS = 2_000
|
|
27
|
+
IPHONE_TIMEOUT_MS_DEFAULT = 2_500
|
|
28
|
+
IPHONE_TIMEOUT_MS_MAX = 5_000
|
|
29
|
+
FAST_VIBE_CHECK_TIMEOUT_SECONDS = max(1, min(int(os.environ.get("PAIRLING_FAST_VIBE_TIMEOUT_SECONDS", "6")), 9))
|
|
30
|
+
IPHONE_HOST = os.environ.get("PHONE_TS_HOST", os.environ.get("PAIRLING_PHONE_TOOLS_HOST", "iphone-15-pro"))
|
|
31
|
+
IPHONE_PORT = int(os.environ.get("PHONE_TS_PORT", os.environ.get("PAIRLING_PHONE_TOOLS_PORT", "7724")))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class ToolResult:
|
|
36
|
+
ok: bool
|
|
37
|
+
provider: str | None
|
|
38
|
+
result: str = ""
|
|
39
|
+
reason: str | None = None
|
|
40
|
+
error_message: str | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class PhoneToolAvailabilityStore:
|
|
44
|
+
def __init__(self) -> None:
|
|
45
|
+
self._state: dict[str, Any] = {}
|
|
46
|
+
|
|
47
|
+
def update(self, payload: dict[str, Any], *, now: float | None = None) -> dict[str, Any]:
|
|
48
|
+
current = time.time() if now is None else now
|
|
49
|
+
running = bool(payload.get("listener_running"))
|
|
50
|
+
try:
|
|
51
|
+
expires_in = int(payload.get("expires_in_seconds") or 30)
|
|
52
|
+
except (TypeError, ValueError):
|
|
53
|
+
expires_in = 30
|
|
54
|
+
expires_in = max(1, min(expires_in, 120))
|
|
55
|
+
tools = payload.get("tools")
|
|
56
|
+
if not isinstance(tools, list):
|
|
57
|
+
tools = PHONE_TOOL_LIST
|
|
58
|
+
normalized_tools = sorted({str(tool) for tool in tools if str(tool) in ALLOWED_TOOLS})
|
|
59
|
+
state = {
|
|
60
|
+
"last_seen_at": current,
|
|
61
|
+
"expires_at": current + expires_in if running else current,
|
|
62
|
+
"listener_running": running,
|
|
63
|
+
"port": _bounded_int(payload.get("port"), default=IPHONE_PORT, minimum=1, maximum=65535),
|
|
64
|
+
"tools": normalized_tools,
|
|
65
|
+
"app_state": str(payload.get("app_state") or "unknown")[:40],
|
|
66
|
+
}
|
|
67
|
+
self._state = state
|
|
68
|
+
return self.snapshot(now=current)
|
|
69
|
+
|
|
70
|
+
def snapshot(self, *, now: float | None = None) -> dict[str, Any]:
|
|
71
|
+
current = time.time() if now is None else now
|
|
72
|
+
state = dict(self._state)
|
|
73
|
+
state["fresh"] = self.is_fresh(now=current)
|
|
74
|
+
return state
|
|
75
|
+
|
|
76
|
+
def is_fresh(self, tool: str | None = None, *, now: float | None = None) -> bool:
|
|
77
|
+
current = time.time() if now is None else now
|
|
78
|
+
if not self._state.get("listener_running"):
|
|
79
|
+
return False
|
|
80
|
+
if float(self._state.get("expires_at") or 0) <= current:
|
|
81
|
+
return False
|
|
82
|
+
if tool and tool not in set(self._state.get("tools") or []):
|
|
83
|
+
return False
|
|
84
|
+
return True
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
PHONE_TOOL_AVAILABILITY = PhoneToolAvailabilityStore()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class PhoneToolWorkQueue:
|
|
91
|
+
def __init__(self) -> None:
|
|
92
|
+
self._condition = threading.Condition(threading.RLock())
|
|
93
|
+
self._pending: list[dict[str, Any]] = []
|
|
94
|
+
self._inflight: dict[str, dict[str, Any]] = {}
|
|
95
|
+
self._results: dict[str, ToolResult] = {}
|
|
96
|
+
self._poller_state: dict[str, Any] = {}
|
|
97
|
+
|
|
98
|
+
def report_poller(
|
|
99
|
+
self,
|
|
100
|
+
*,
|
|
101
|
+
device_id: str | None,
|
|
102
|
+
tools: list[str] | None,
|
|
103
|
+
now: float | None = None,
|
|
104
|
+
expires_in_seconds: int = 30,
|
|
105
|
+
) -> dict[str, Any]:
|
|
106
|
+
current = time.time() if now is None else now
|
|
107
|
+
normalized_tools = sorted({str(tool) for tool in (tools or PHONE_TOOL_LIST) if str(tool) in ALLOWED_TOOLS})
|
|
108
|
+
expires = current + max(1, min(int(expires_in_seconds or 30), 120))
|
|
109
|
+
with self._condition:
|
|
110
|
+
self._poller_state = {
|
|
111
|
+
"device_id": device_id,
|
|
112
|
+
"last_seen_at": current,
|
|
113
|
+
"expires_at": expires,
|
|
114
|
+
"tools": normalized_tools,
|
|
115
|
+
}
|
|
116
|
+
self._condition.notify_all()
|
|
117
|
+
return self.snapshot(now=current)
|
|
118
|
+
|
|
119
|
+
def snapshot(self, *, now: float | None = None) -> dict[str, Any]:
|
|
120
|
+
current = time.time() if now is None else now
|
|
121
|
+
state = dict(self._poller_state)
|
|
122
|
+
state["fresh"] = self.is_fresh(now=current)
|
|
123
|
+
return state
|
|
124
|
+
|
|
125
|
+
def is_fresh(self, tool: str | None = None, *, now: float | None = None) -> bool:
|
|
126
|
+
current = time.time() if now is None else now
|
|
127
|
+
with self._condition:
|
|
128
|
+
if float(self._poller_state.get("expires_at") or 0) <= current:
|
|
129
|
+
return False
|
|
130
|
+
if tool and tool not in set(self._poller_state.get("tools") or []):
|
|
131
|
+
return False
|
|
132
|
+
return bool(self._poller_state.get("device_id"))
|
|
133
|
+
|
|
134
|
+
def next_request(
|
|
135
|
+
self,
|
|
136
|
+
*,
|
|
137
|
+
device_id: str | None,
|
|
138
|
+
tools: list[str] | None,
|
|
139
|
+
wait_seconds: int,
|
|
140
|
+
now: Callable[[], float] = time.time,
|
|
141
|
+
) -> dict[str, Any] | None:
|
|
142
|
+
normalized_tools = sorted({str(tool) for tool in (tools or PHONE_TOOL_LIST) if str(tool) in ALLOWED_TOOLS})
|
|
143
|
+
wait_seconds = max(1, min(int(wait_seconds or 10), 25))
|
|
144
|
+
deadline = now() + wait_seconds
|
|
145
|
+
with self._condition:
|
|
146
|
+
self.report_poller(
|
|
147
|
+
device_id=device_id,
|
|
148
|
+
tools=normalized_tools,
|
|
149
|
+
now=now(),
|
|
150
|
+
expires_in_seconds=wait_seconds + 20,
|
|
151
|
+
)
|
|
152
|
+
while True:
|
|
153
|
+
self._prune_locked(now())
|
|
154
|
+
for idx, request in enumerate(self._pending):
|
|
155
|
+
if request["tool"] in normalized_tools:
|
|
156
|
+
request = self._pending.pop(idx)
|
|
157
|
+
request["assigned_device_id"] = device_id
|
|
158
|
+
self._inflight[request["request_id"]] = request
|
|
159
|
+
return {
|
|
160
|
+
"request_id": request["request_id"],
|
|
161
|
+
"tool": request["tool"],
|
|
162
|
+
"input": request["input"],
|
|
163
|
+
"created_at": request["created_at"],
|
|
164
|
+
}
|
|
165
|
+
remaining = deadline - now()
|
|
166
|
+
if remaining <= 0:
|
|
167
|
+
return None
|
|
168
|
+
self._condition.wait(timeout=remaining)
|
|
169
|
+
|
|
170
|
+
def complete(
|
|
171
|
+
self,
|
|
172
|
+
*,
|
|
173
|
+
request_id: str,
|
|
174
|
+
ok: bool,
|
|
175
|
+
result: str = "",
|
|
176
|
+
error: str = "",
|
|
177
|
+
) -> bool:
|
|
178
|
+
request_id = str(request_id or "")
|
|
179
|
+
if not request_id:
|
|
180
|
+
return False
|
|
181
|
+
with self._condition:
|
|
182
|
+
request = self._inflight.pop(request_id, None)
|
|
183
|
+
if request is None:
|
|
184
|
+
return False
|
|
185
|
+
self._results[request_id] = ToolResult(
|
|
186
|
+
bool(ok),
|
|
187
|
+
"iphone",
|
|
188
|
+
result=str(result or ""),
|
|
189
|
+
reason=None if ok else "iphone_tool_failed",
|
|
190
|
+
error_message="" if ok else str(error or "phone tool failed"),
|
|
191
|
+
)
|
|
192
|
+
self._condition.notify_all()
|
|
193
|
+
return True
|
|
194
|
+
|
|
195
|
+
def submit(
|
|
196
|
+
self,
|
|
197
|
+
tool: str,
|
|
198
|
+
input_payload: dict[str, Any],
|
|
199
|
+
*,
|
|
200
|
+
timeout_ms: int,
|
|
201
|
+
now: Callable[[], float] = time.time,
|
|
202
|
+
) -> ToolResult:
|
|
203
|
+
timeout = max(0.05, min(timeout_ms, IPHONE_TIMEOUT_MS_MAX) / 1000)
|
|
204
|
+
request_id = str(uuid.uuid4()).lower()
|
|
205
|
+
deadline = now() + timeout
|
|
206
|
+
request = {
|
|
207
|
+
"request_id": request_id,
|
|
208
|
+
"tool": tool,
|
|
209
|
+
"input": input_payload,
|
|
210
|
+
"created_at": now(),
|
|
211
|
+
"expires_at": deadline,
|
|
212
|
+
}
|
|
213
|
+
with self._condition:
|
|
214
|
+
if not self.is_fresh(tool, now=now()):
|
|
215
|
+
return ToolResult(False, "iphone", reason="iphone_no_reverse_worker", error_message="no fresh phone tool worker")
|
|
216
|
+
self._pending.append(request)
|
|
217
|
+
self._condition.notify_all()
|
|
218
|
+
while True:
|
|
219
|
+
result = self._results.pop(request_id, None)
|
|
220
|
+
if result is not None:
|
|
221
|
+
return result
|
|
222
|
+
remaining = deadline - now()
|
|
223
|
+
if remaining <= 0:
|
|
224
|
+
self._pending = [item for item in self._pending if item["request_id"] != request_id]
|
|
225
|
+
self._inflight.pop(request_id, None)
|
|
226
|
+
return ToolResult(False, "iphone", reason="iphone_timeout", error_message="timed out")
|
|
227
|
+
self._condition.wait(timeout=remaining)
|
|
228
|
+
|
|
229
|
+
def _prune_locked(self, current: float) -> None:
|
|
230
|
+
self._pending = [item for item in self._pending if float(item.get("expires_at") or 0) > current]
|
|
231
|
+
expired = [
|
|
232
|
+
request_id
|
|
233
|
+
for request_id, item in self._inflight.items()
|
|
234
|
+
if float(item.get("expires_at") or 0) <= current
|
|
235
|
+
]
|
|
236
|
+
for request_id in expired:
|
|
237
|
+
self._inflight.pop(request_id, None)
|
|
238
|
+
self._results.pop(request_id, None)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
PHONE_TOOL_WORK_QUEUE = PhoneToolWorkQueue()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class PhoneToolClient:
|
|
245
|
+
def __init__(
|
|
246
|
+
self,
|
|
247
|
+
*,
|
|
248
|
+
host: str = IPHONE_HOST,
|
|
249
|
+
port: int = IPHONE_PORT,
|
|
250
|
+
token: str | None = None,
|
|
251
|
+
work_queue: PhoneToolWorkQueue | None = None,
|
|
252
|
+
) -> None:
|
|
253
|
+
self.host = host
|
|
254
|
+
self.port = port
|
|
255
|
+
self.token = token if token is not None else _load_phone_token()
|
|
256
|
+
self.work_queue = work_queue or PHONE_TOOL_WORK_QUEUE
|
|
257
|
+
|
|
258
|
+
def is_available(self, tool: str, *, now: float | None = None) -> bool:
|
|
259
|
+
if os.environ.get("PAIRLING_PHONE_TOOLS_DIRECT_TCP") == "1":
|
|
260
|
+
return True
|
|
261
|
+
return self.work_queue.is_fresh(tool, now=now)
|
|
262
|
+
|
|
263
|
+
def run(self, tool: str, input_payload: dict[str, Any], *, timeout_ms: int) -> ToolResult:
|
|
264
|
+
if os.environ.get("PAIRLING_PHONE_TOOLS_DIRECT_TCP") != "1":
|
|
265
|
+
return self.work_queue.submit(tool, input_payload, timeout_ms=timeout_ms)
|
|
266
|
+
if not self.token:
|
|
267
|
+
return ToolResult(False, "iphone", reason="iphone_not_configured", error_message="phone token missing")
|
|
268
|
+
request = json.dumps({
|
|
269
|
+
"tool": tool,
|
|
270
|
+
"token": self.token,
|
|
271
|
+
"input": input_payload,
|
|
272
|
+
}, separators=(",", ":")) + "\n"
|
|
273
|
+
timeout = max(0.05, min(timeout_ms, IPHONE_TIMEOUT_MS_MAX) / 1000)
|
|
274
|
+
try:
|
|
275
|
+
with socket.create_connection((self.host, self.port), timeout=timeout) as sock:
|
|
276
|
+
sock.settimeout(timeout)
|
|
277
|
+
sock.sendall(request.encode("utf-8"))
|
|
278
|
+
buf = bytearray()
|
|
279
|
+
while True:
|
|
280
|
+
chunk = sock.recv(65536)
|
|
281
|
+
if not chunk:
|
|
282
|
+
break
|
|
283
|
+
buf.extend(chunk)
|
|
284
|
+
if b"\n" in buf:
|
|
285
|
+
break
|
|
286
|
+
except ConnectionRefusedError as exc:
|
|
287
|
+
return ToolResult(False, "iphone", reason="iphone_connection_refused", error_message=str(exc))
|
|
288
|
+
except socket.timeout as exc:
|
|
289
|
+
return ToolResult(False, "iphone", reason="iphone_timeout", error_message=str(exc))
|
|
290
|
+
except OSError as exc:
|
|
291
|
+
return ToolResult(False, "iphone", reason="iphone_unavailable", error_message=str(exc))
|
|
292
|
+
|
|
293
|
+
line = bytes(buf).split(b"\n", 1)[0]
|
|
294
|
+
try:
|
|
295
|
+
response = json.loads(line.decode("utf-8"))
|
|
296
|
+
except Exception as exc:
|
|
297
|
+
return ToolResult(False, "iphone", reason="iphone_bad_response", error_message=str(exc))
|
|
298
|
+
if not response.get("ok"):
|
|
299
|
+
message = str(response.get("error") or "unknown error")
|
|
300
|
+
reason = "iphone_token_rejected" if "token" in message.lower() else "iphone_tool_failed"
|
|
301
|
+
return ToolResult(False, "iphone", reason=reason, error_message=message)
|
|
302
|
+
return ToolResult(True, "iphone", result=str(response.get("result") or ""))
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class MacToolRunner:
|
|
306
|
+
def run(
|
|
307
|
+
self,
|
|
308
|
+
tool: str,
|
|
309
|
+
input_payload: dict[str, Any],
|
|
310
|
+
*,
|
|
311
|
+
model: str,
|
|
312
|
+
max_output_chars: int,
|
|
313
|
+
) -> ToolResult:
|
|
314
|
+
try:
|
|
315
|
+
if tool == "corpus_recall":
|
|
316
|
+
return ToolResult(
|
|
317
|
+
True,
|
|
318
|
+
"mac_fallback",
|
|
319
|
+
result=_truncate_output(_search_local_corpus(str(input_payload.get("query") or "")), max_output_chars),
|
|
320
|
+
)
|
|
321
|
+
system, prompt = _mac_prompt(tool, input_payload)
|
|
322
|
+
result = run_local_llm(
|
|
323
|
+
model=model,
|
|
324
|
+
prompt=prompt,
|
|
325
|
+
system=system,
|
|
326
|
+
timeout_seconds=FAST_VIBE_CHECK_TIMEOUT_SECONDS if tool == "vibe_check" else 120,
|
|
327
|
+
)
|
|
328
|
+
return ToolResult(True, "mac_fallback", result=_truncate_output(result, max_output_chars))
|
|
329
|
+
except LLMRouteError as exc:
|
|
330
|
+
if tool == "vibe_check":
|
|
331
|
+
return ToolResult(
|
|
332
|
+
True,
|
|
333
|
+
"mac_fallback",
|
|
334
|
+
result=_truncate_output(_deterministic_vibe_check(str(input_payload.get("draft") or ""), reason=exc.code), max_output_chars),
|
|
335
|
+
reason=f"fast_vibe_check_after_{exc.code}",
|
|
336
|
+
)
|
|
337
|
+
return ToolResult(False, "mac_fallback", reason=exc.code, error_message=exc.message)
|
|
338
|
+
except Exception as exc:
|
|
339
|
+
return ToolResult(False, "mac_fallback", reason=type(exc).__name__, error_message=str(exc))
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def run_pairling_tool(
|
|
343
|
+
payload: dict[str, Any],
|
|
344
|
+
*,
|
|
345
|
+
iphone_client: PhoneToolClient | None = None,
|
|
346
|
+
mac_runner: Any | None = None,
|
|
347
|
+
availability: PhoneToolAvailabilityStore | None = None,
|
|
348
|
+
now: Callable[[], float] = time.time,
|
|
349
|
+
) -> dict[str, Any]:
|
|
350
|
+
started = now()
|
|
351
|
+
if not isinstance(payload, dict):
|
|
352
|
+
return _error("bad_request", "request must be a JSON object", started, now)
|
|
353
|
+
tool = str(payload.get("tool") or "")
|
|
354
|
+
if tool not in ALLOWED_TOOLS:
|
|
355
|
+
return _error("invalid_tool", "tool must be one of: " + ", ".join(sorted(ALLOWED_TOOLS)), started, now, tool=tool)
|
|
356
|
+
strategy = str(payload.get("strategy") or "auto")
|
|
357
|
+
if strategy not in ALLOWED_STRATEGIES:
|
|
358
|
+
return _error("invalid_strategy", "strategy must be auto|iphone_only|mac_only", started, now, tool=tool)
|
|
359
|
+
input_payload = _bounded_input(payload.get("input") if isinstance(payload.get("input"), dict) else {}, _bounded_int(payload.get("max_input_chars"), default=MAX_INPUT_CHARS, minimum=1, maximum=MAX_INPUT_CHARS))
|
|
360
|
+
missing = _missing_required_field(tool, input_payload)
|
|
361
|
+
if missing:
|
|
362
|
+
return _error("missing_input", f"missing input field '{missing}'", started, now, tool=tool)
|
|
363
|
+
iphone_timeout_ms = _bounded_int(payload.get("iphone_timeout_ms"), default=IPHONE_TIMEOUT_MS_DEFAULT, minimum=50, maximum=IPHONE_TIMEOUT_MS_MAX)
|
|
364
|
+
max_output_chars = _bounded_int(payload.get("max_output_chars"), default=MAX_OUTPUT_CHARS, minimum=64, maximum=MAX_OUTPUT_CHARS)
|
|
365
|
+
mac_model = str(payload.get("mac_model") or "sonnet")
|
|
366
|
+
|
|
367
|
+
store = availability or PHONE_TOOL_AVAILABILITY
|
|
368
|
+
phone = iphone_client or PhoneToolClient()
|
|
369
|
+
mac = mac_runner or MacToolRunner()
|
|
370
|
+
iphone_attempted = False
|
|
371
|
+
iphone_reason: str | None = None
|
|
372
|
+
iphone_error: str | None = None
|
|
373
|
+
|
|
374
|
+
iphone_ready = store.is_fresh(tool, now=started) or (
|
|
375
|
+
hasattr(phone, "is_available") and bool(phone.is_available(tool, now=started))
|
|
376
|
+
)
|
|
377
|
+
if strategy == "iphone_only" or (strategy == "auto" and iphone_ready):
|
|
378
|
+
iphone_attempted = True
|
|
379
|
+
phone_result = phone.run(tool, input_payload, timeout_ms=iphone_timeout_ms)
|
|
380
|
+
if phone_result.ok:
|
|
381
|
+
return _success(
|
|
382
|
+
tool=tool,
|
|
383
|
+
provider="iphone",
|
|
384
|
+
strategy=strategy,
|
|
385
|
+
result=_truncate_output(phone_result.result, max_output_chars),
|
|
386
|
+
fallback_reason=None,
|
|
387
|
+
started=started,
|
|
388
|
+
now=now,
|
|
389
|
+
diagnostics=_diagnostics(True, phone, mac_model),
|
|
390
|
+
)
|
|
391
|
+
iphone_reason = phone_result.reason or "iphone_unavailable"
|
|
392
|
+
iphone_error = phone_result.error_message
|
|
393
|
+
if strategy == "iphone_only":
|
|
394
|
+
return _provider_error(
|
|
395
|
+
tool=tool,
|
|
396
|
+
strategy=strategy,
|
|
397
|
+
provider="iphone",
|
|
398
|
+
code=iphone_reason,
|
|
399
|
+
message=iphone_error or iphone_reason,
|
|
400
|
+
started=started,
|
|
401
|
+
now=now,
|
|
402
|
+
diagnostics=_diagnostics(True, phone, mac_model),
|
|
403
|
+
)
|
|
404
|
+
elif strategy == "auto":
|
|
405
|
+
iphone_reason = "iphone_heartbeat_stale"
|
|
406
|
+
else:
|
|
407
|
+
iphone_reason = "iphone_disabled_by_strategy"
|
|
408
|
+
|
|
409
|
+
if strategy == "mac_only" or strategy == "auto":
|
|
410
|
+
mac_result = mac.run(tool, input_payload, model=mac_model, max_output_chars=max_output_chars)
|
|
411
|
+
if mac_result.ok:
|
|
412
|
+
return _success(
|
|
413
|
+
tool=tool,
|
|
414
|
+
provider="mac_fallback",
|
|
415
|
+
strategy=strategy,
|
|
416
|
+
result=mac_result.result,
|
|
417
|
+
fallback_reason=None if strategy == "mac_only" else iphone_reason,
|
|
418
|
+
started=started,
|
|
419
|
+
now=now,
|
|
420
|
+
diagnostics=_diagnostics(iphone_attempted, phone, mac_model),
|
|
421
|
+
)
|
|
422
|
+
return _all_failed(tool, strategy, iphone_reason, mac_result.reason or "mac_failed", mac_result.error_message, started, now, iphone_error=iphone_error)
|
|
423
|
+
|
|
424
|
+
return _error("invalid_strategy", "strategy did not select a provider", started, now, tool=tool)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def audit_detail_for_tool_run(request_payload: dict[str, Any], result: dict[str, Any]) -> dict[str, Any]:
|
|
428
|
+
input_payload = request_payload.get("input") if isinstance(request_payload.get("input"), dict) else {}
|
|
429
|
+
input_json = json.dumps(input_payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
430
|
+
return {
|
|
431
|
+
"tool": str(request_payload.get("tool") or result.get("tool") or "")[:80],
|
|
432
|
+
"provider": result.get("provider"),
|
|
433
|
+
"strategy": result.get("strategy"),
|
|
434
|
+
"fallback_reason": result.get("fallback_reason"),
|
|
435
|
+
"input_length": len(input_json),
|
|
436
|
+
"input_sha256": hashlib.sha256(input_json.encode("utf-8")).hexdigest(),
|
|
437
|
+
"latency_ms": result.get("latency_ms"),
|
|
438
|
+
"ok": bool(result.get("ok")),
|
|
439
|
+
"error_code": ((result.get("error") or {}) if isinstance(result.get("error"), dict) else {}).get("code"),
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _success(
|
|
444
|
+
*,
|
|
445
|
+
tool: str,
|
|
446
|
+
provider: str,
|
|
447
|
+
strategy: str,
|
|
448
|
+
result: str,
|
|
449
|
+
fallback_reason: str | None,
|
|
450
|
+
started: float,
|
|
451
|
+
now: Callable[[], float],
|
|
452
|
+
diagnostics: dict[str, Any],
|
|
453
|
+
) -> dict[str, Any]:
|
|
454
|
+
return {
|
|
455
|
+
"ok": True,
|
|
456
|
+
"schema_version": SCHEMA_VERSION,
|
|
457
|
+
"tool": tool,
|
|
458
|
+
"provider": provider,
|
|
459
|
+
"strategy": strategy,
|
|
460
|
+
"fallback_reason": fallback_reason,
|
|
461
|
+
"latency_ms": max(0, int((now() - started) * 1000)),
|
|
462
|
+
"result": result,
|
|
463
|
+
"diagnostics": diagnostics,
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _provider_error(
|
|
468
|
+
*,
|
|
469
|
+
tool: str,
|
|
470
|
+
strategy: str,
|
|
471
|
+
provider: str,
|
|
472
|
+
code: str,
|
|
473
|
+
message: str,
|
|
474
|
+
started: float,
|
|
475
|
+
now: Callable[[], float],
|
|
476
|
+
diagnostics: dict[str, Any],
|
|
477
|
+
) -> dict[str, Any]:
|
|
478
|
+
return {
|
|
479
|
+
"ok": False,
|
|
480
|
+
"schema_version": SCHEMA_VERSION,
|
|
481
|
+
"tool": tool,
|
|
482
|
+
"provider": provider,
|
|
483
|
+
"strategy": strategy,
|
|
484
|
+
"fallback_reason": code,
|
|
485
|
+
"latency_ms": max(0, int((now() - started) * 1000)),
|
|
486
|
+
"error": {"code": code, "message": message},
|
|
487
|
+
"diagnostics": diagnostics,
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _all_failed(
|
|
492
|
+
tool: str,
|
|
493
|
+
strategy: str,
|
|
494
|
+
iphone_reason: str | None,
|
|
495
|
+
mac_reason: str,
|
|
496
|
+
mac_message: str | None,
|
|
497
|
+
started: float,
|
|
498
|
+
now: Callable[[], float],
|
|
499
|
+
*,
|
|
500
|
+
iphone_error: str | None = None,
|
|
501
|
+
) -> dict[str, Any]:
|
|
502
|
+
return {
|
|
503
|
+
"ok": False,
|
|
504
|
+
"schema_version": SCHEMA_VERSION,
|
|
505
|
+
"tool": tool,
|
|
506
|
+
"provider": None,
|
|
507
|
+
"strategy": strategy,
|
|
508
|
+
"fallback_reason": iphone_reason,
|
|
509
|
+
"latency_ms": max(0, int((now() - started) * 1000)),
|
|
510
|
+
"error": {
|
|
511
|
+
"code": "all_providers_failed",
|
|
512
|
+
"message": "iPhone listener was unavailable and Mac fallback failed.",
|
|
513
|
+
"iphone_reason": iphone_reason,
|
|
514
|
+
"iphone_message": iphone_error,
|
|
515
|
+
"mac_reason": mac_reason,
|
|
516
|
+
"mac_message": mac_message,
|
|
517
|
+
},
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _error(code: str, message: str, started: float, now: Callable[[], float], *, tool: str = "") -> dict[str, Any]:
|
|
522
|
+
return {
|
|
523
|
+
"ok": False,
|
|
524
|
+
"schema_version": SCHEMA_VERSION,
|
|
525
|
+
"tool": tool,
|
|
526
|
+
"provider": None,
|
|
527
|
+
"latency_ms": max(0, int((now() - started) * 1000)),
|
|
528
|
+
"error": {"code": code, "message": message},
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _diagnostics(iphone_attempted: bool, phone: PhoneToolClient, mac_model: str) -> dict[str, Any]:
|
|
533
|
+
return {
|
|
534
|
+
"iphone_attempted": iphone_attempted,
|
|
535
|
+
"iphone_host": phone.host,
|
|
536
|
+
"iphone_port": phone.port,
|
|
537
|
+
"mac_model": mac_model,
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _missing_required_field(tool: str, input_payload: dict[str, Any]) -> str | None:
|
|
542
|
+
if tool == "vibe_check":
|
|
543
|
+
return None if input_payload.get("draft") else "draft"
|
|
544
|
+
if tool == "second_opinion":
|
|
545
|
+
return None if input_payload.get("claim") else "claim"
|
|
546
|
+
if tool == "user_likely_prefers":
|
|
547
|
+
if not input_payload.get("option_a"):
|
|
548
|
+
return "option_a"
|
|
549
|
+
if not input_payload.get("option_b"):
|
|
550
|
+
return "option_b"
|
|
551
|
+
if tool == "corpus_recall":
|
|
552
|
+
return None if input_payload.get("query") else "query"
|
|
553
|
+
return None
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _bounded_input(value: dict[str, Any], max_chars: int) -> dict[str, Any]:
|
|
557
|
+
bounded: dict[str, Any] = {}
|
|
558
|
+
for key, item in value.items():
|
|
559
|
+
if isinstance(item, str):
|
|
560
|
+
bounded[str(key)] = item[:max_chars]
|
|
561
|
+
else:
|
|
562
|
+
bounded[str(key)] = item
|
|
563
|
+
return bounded
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _bounded_int(value: Any, *, default: int, minimum: int, maximum: int) -> int:
|
|
567
|
+
try:
|
|
568
|
+
parsed = int(value)
|
|
569
|
+
except (TypeError, ValueError):
|
|
570
|
+
parsed = default
|
|
571
|
+
return max(minimum, min(parsed, maximum))
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _truncate_output(value: str, max_chars: int) -> str:
|
|
575
|
+
text = str(value or "")
|
|
576
|
+
if len(text) <= max_chars:
|
|
577
|
+
return text
|
|
578
|
+
suffix = "\n[output truncated]"
|
|
579
|
+
if max_chars <= len(suffix):
|
|
580
|
+
return text[:max_chars]
|
|
581
|
+
return text[: max_chars - len(suffix)] + suffix
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _mac_prompt(tool: str, input_payload: dict[str, Any]) -> tuple[str, str]:
|
|
585
|
+
if tool == "vibe_check":
|
|
586
|
+
return (
|
|
587
|
+
"You are checking whether a draft sounds like Mergim's usual voice. Return one of: yes, partial, no. Then give one concrete edit. Be concise. Do not rewrite the whole draft unless asked.",
|
|
588
|
+
"Draft:\n" + str(input_payload.get("draft") or ""),
|
|
589
|
+
)
|
|
590
|
+
if tool == "second_opinion":
|
|
591
|
+
return (
|
|
592
|
+
"Give the strongest skeptical counterargument or risk in 2-3 sentences. Be specific and practical.",
|
|
593
|
+
"Claim:\n" + str(input_payload.get("claim") or ""),
|
|
594
|
+
)
|
|
595
|
+
if tool == "user_likely_prefers":
|
|
596
|
+
return (
|
|
597
|
+
"Choose A or B based on the user's known preference for velocity, simplicity, and directness. Return exactly \"A - ...\" or \"B - ...\" with one sentence of rationale.",
|
|
598
|
+
"Option A:\n"
|
|
599
|
+
+ str(input_payload.get("option_a") or "")
|
|
600
|
+
+ "\n\nOption B:\n"
|
|
601
|
+
+ str(input_payload.get("option_b") or ""),
|
|
602
|
+
)
|
|
603
|
+
raise ValueError(f"unsupported mac fallback tool: {tool}")
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _deterministic_vibe_check(draft: str, *, reason: str) -> str:
|
|
607
|
+
text = " ".join(str(draft or "").split())
|
|
608
|
+
lowered = text.lower()
|
|
609
|
+
issues: list[str] = []
|
|
610
|
+
edit = ""
|
|
611
|
+
|
|
612
|
+
formal_phrases = [
|
|
613
|
+
"thank you for sending this across",
|
|
614
|
+
"please could you",
|
|
615
|
+
"i am writing to",
|
|
616
|
+
"i would like to",
|
|
617
|
+
"kindly",
|
|
618
|
+
"further to",
|
|
619
|
+
]
|
|
620
|
+
if any(phrase in lowered for phrase in formal_phrases):
|
|
621
|
+
issues.append("a little more formal than Mergim's usual direct style")
|
|
622
|
+
edit = "open with the ask directly and drop the polite padding."
|
|
623
|
+
if len(text) > 700:
|
|
624
|
+
issues.append("too long for a quick operational message")
|
|
625
|
+
edit = edit or "split the asks into short bullets or remove background detail."
|
|
626
|
+
if re.search(r"\b(just|perhaps|maybe|i was wondering)\b", lowered):
|
|
627
|
+
issues.append("slightly hedged")
|
|
628
|
+
edit = edit or "remove the hedge and state the request plainly."
|
|
629
|
+
if not issues:
|
|
630
|
+
verdict = "yes"
|
|
631
|
+
issue = "clear, practical, and direct"
|
|
632
|
+
edit = "keep it as-is, or trim one greeting word if you want it tighter."
|
|
633
|
+
else:
|
|
634
|
+
verdict = "partial"
|
|
635
|
+
issue = issues[0]
|
|
636
|
+
|
|
637
|
+
return (
|
|
638
|
+
f"{verdict} - Fast Pairling fallback ({reason}). The draft is {issue}. "
|
|
639
|
+
f"One concrete edit: {edit}"
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _search_local_corpus(query: str, *, limit: int = 5) -> str:
|
|
644
|
+
terms = [term.lower() for term in re.findall(r"[A-Za-z0-9_./:-]{2,}", query)[:12]]
|
|
645
|
+
if not terms:
|
|
646
|
+
return "No matches in the local corpus."
|
|
647
|
+
candidates: list[tuple[float, str, str, str]] = []
|
|
648
|
+
roots = [
|
|
649
|
+
Path.home() / ".claude" / "projects",
|
|
650
|
+
Path.home() / ".codex" / "sessions",
|
|
651
|
+
]
|
|
652
|
+
for root in roots:
|
|
653
|
+
if not root.exists():
|
|
654
|
+
continue
|
|
655
|
+
files = sorted(root.rglob("*.jsonl"), key=lambda path: path.stat().st_mtime if path.exists() else 0, reverse=True)[:250]
|
|
656
|
+
for path in files:
|
|
657
|
+
try:
|
|
658
|
+
text = path.read_text(errors="replace")
|
|
659
|
+
mtime = path.stat().st_mtime
|
|
660
|
+
except OSError:
|
|
661
|
+
continue
|
|
662
|
+
lowered = text.lower()
|
|
663
|
+
score = sum(lowered.count(term) * (3 if any(ch in term for ch in "/_.:-") else 1) for term in terms)
|
|
664
|
+
if score <= 0:
|
|
665
|
+
continue
|
|
666
|
+
snippet = _snippet_for_terms(text, terms)
|
|
667
|
+
candidates.append((score + (mtime / 10_000_000_000), str(path), path.stem, snippet))
|
|
668
|
+
candidates.sort(key=lambda item: item[0], reverse=True)
|
|
669
|
+
if not candidates:
|
|
670
|
+
return "No matches in the local corpus."
|
|
671
|
+
lines = []
|
|
672
|
+
for index, (_, path, session_id, snippet) in enumerate(candidates[:limit], start=1):
|
|
673
|
+
project = _project_label(path)
|
|
674
|
+
lines.append(f"{index}. [{project}] {session_id}: {snippet}")
|
|
675
|
+
return "Top local matches:\n" + "\n".join(lines)
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _snippet_for_terms(text: str, terms: list[str]) -> str:
|
|
679
|
+
lowered = text.lower()
|
|
680
|
+
positions = [lowered.find(term) for term in terms if lowered.find(term) >= 0]
|
|
681
|
+
start = max(0, min(positions) - 80) if positions else 0
|
|
682
|
+
snippet = text[start:start + 260].replace("\n", " ")
|
|
683
|
+
return re.sub(r"\s+", " ", snippet).strip()
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _project_label(path: str) -> str:
|
|
687
|
+
parts = Path(path).parts
|
|
688
|
+
for marker in ("projects", "sessions"):
|
|
689
|
+
if marker in parts:
|
|
690
|
+
idx = parts.index(marker)
|
|
691
|
+
if idx + 1 < len(parts):
|
|
692
|
+
return parts[idx + 1]
|
|
693
|
+
return Path(path).parent.name
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _load_phone_token() -> str | None:
|
|
697
|
+
for key in ("PAIRLING_PHONE_TOOLS_TOKEN", "PHONE_TOKEN"):
|
|
698
|
+
value = os.environ.get(key)
|
|
699
|
+
if value:
|
|
700
|
+
return value.strip()
|
|
701
|
+
token_file = Path(os.environ.get("PHONE_TOKEN_FILE", str(Path.home() / ".claude" / "scripts" / ".notify-token")))
|
|
702
|
+
try:
|
|
703
|
+
value = token_file.read_text().strip()
|
|
704
|
+
return value or None
|
|
705
|
+
except OSError:
|
|
706
|
+
return None
|