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.
Files changed (61) hide show
  1. package/package.json +5 -1
  2. package/payload/mac/SOURCE_BRANCH +1 -0
  3. package/payload/mac/SOURCE_DIRTY +1 -0
  4. package/payload/mac/SOURCE_REVISION +1 -0
  5. package/payload/mac/VERSION +1 -0
  6. package/payload/mac/companiond/integrations/__init__.py +1 -0
  7. package/payload/mac/companiond/integrations/aperture_cli/__init__.py +23 -0
  8. package/payload/mac/companiond/integrations/aperture_cli/launch.py +456 -0
  9. package/payload/mac/companiond/integrations/aperture_cli/status.py +393 -0
  10. package/payload/mac/companiond/live_activity_publisher.py +380 -0
  11. package/payload/mac/companiond/llm_route.py +108 -0
  12. package/payload/mac/companiond/local_mcp_bridge.py +156 -0
  13. package/payload/mac/companiond/model_status_contract.py +101 -0
  14. package/payload/mac/companiond/pairdrop_store.py +920 -0
  15. package/payload/mac/companiond/pairling_connectd_status.py +149 -0
  16. package/payload/mac/companiond/pairling_devices.py +459 -0
  17. package/payload/mac/companiond/pairling_pairing.py +404 -0
  18. package/payload/mac/companiond/pairling_relay_claims.py +232 -0
  19. package/payload/mac/companiond/pairling_tools.py +706 -0
  20. package/payload/mac/companiond/pairlingd.py +18438 -0
  21. package/payload/mac/companiond/providers/__init__.py +1 -0
  22. package/payload/mac/companiond/providers/base.py +255 -0
  23. package/payload/mac/companiond/providers/claude.py +127 -0
  24. package/payload/mac/companiond/providers/codex.py +124 -0
  25. package/payload/mac/companiond/providers/external.py +46 -0
  26. package/payload/mac/companiond/providers/registry.py +70 -0
  27. package/payload/mac/companiond/pty_broker.py +887 -0
  28. package/payload/mac/companiond/push_dispatcher.py +1990 -0
  29. package/payload/mac/companiond/push_event_catalog.py +566 -0
  30. package/payload/mac/companiond/request_proof.py +142 -0
  31. package/payload/mac/companiond/runtime_contract.py +47 -0
  32. package/payload/mac/companiond/runtime_manifest.py +197 -0
  33. package/payload/mac/companiond/runtime_paths.py +87 -0
  34. package/payload/mac/companiond/safety_monitor.py +542 -0
  35. package/payload/mac/companiond/sentinel_notifications.py +491 -0
  36. package/payload/mac/companiond/standard_push_publisher.py +516 -0
  37. package/payload/mac/companiond/substrate_status_contract.py +139 -0
  38. package/payload/mac/companiond/terminal_screen_backend.py +332 -0
  39. package/payload/mac/companiond/terminal_text_sanitizer.py +54 -0
  40. package/payload/mac/companiond/workstate_feed_contract.py +108 -0
  41. package/payload/mac/connectd/cmd/pairling-connectd/auth_open_test.go +116 -0
  42. package/payload/mac/connectd/cmd/pairling-connectd/main.go +345 -0
  43. package/payload/mac/connectd/cmd/pairling-connectd/upstream_health_test.go +33 -0
  44. package/payload/mac/connectd/go.mod +51 -0
  45. package/payload/mac/connectd/go.sum +229 -0
  46. package/payload/mac/connectd/internal/gateway/proxy.go +597 -0
  47. package/payload/mac/connectd/internal/gateway/proxy_test.go +531 -0
  48. package/payload/mac/connectd/internal/runtime/config.go +99 -0
  49. package/payload/mac/connectd/internal/runtime/config_test.go +29 -0
  50. package/payload/mac/connectd/internal/status/status.go +300 -0
  51. package/payload/mac/connectd/internal/status/status_test.go +263 -0
  52. package/payload/mac/guardian/companion-power-guardian.py +613 -0
  53. package/payload/mac/guardian/guardian_contract.py +67 -0
  54. package/payload/mac/install/bootstrap-first-run.sh +206 -0
  55. package/payload/mac/install/doctor.sh +660 -0
  56. package/payload/mac/install/install-runtime.sh +1241 -0
  57. package/payload/mac/install/render-launchd.py +119 -0
  58. package/payload/mac/install/uninstall-runtime.sh +136 -0
  59. package/payload/mac/mcp/phone_tools.py +210 -0
  60. package/payload/mac/packaging/bin/pairling +63 -0
  61. 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