jvpilot 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- app/__init__.py +1 -0
- app/__main__.py +5 -0
- app/app_catalog.py +55 -0
- app/cli.py +362 -0
- app/config.py +49 -0
- app/decision.py +282 -0
- app/desktop.py +441 -0
- app/interactions.py +548 -0
- app/models.py +61 -0
- app/runtime.py +369 -0
- app/safety.py +65 -0
- app/semantics.py +302 -0
- app/speech_model.py +30 -0
- app/speech_runtime.py +260 -0
- app/text_input.py +424 -0
- app/verification.py +153 -0
- app/web_navigation.py +154 -0
- app/workflows.py +218 -0
- jvpilot-0.1.0.dist-info/METADATA +185 -0
- jvpilot-0.1.0.dist-info/RECORD +24 -0
- jvpilot-0.1.0.dist-info/WHEEL +5 -0
- jvpilot-0.1.0.dist-info/entry_points.txt +2 -0
- jvpilot-0.1.0.dist-info/licenses/LICENSE +21 -0
- jvpilot-0.1.0.dist-info/top_level.txt +1 -0
app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Python-first Jev Pilot runtime."""
|
app/__main__.py
ADDED
app/app_catalog.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Discover launchable app bundles without traversing their embedded helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import plistlib
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
APPLICATION_ROOTS = (Path("/Applications"), Path.home() / "Applications", Path("/System/Applications"),
|
|
12
|
+
Path("/System/Library/CoreServices"))
|
|
13
|
+
|
|
14
|
+
# Familiar spoken names supplement actual installed bundle names.
|
|
15
|
+
APP_ALIASES = (
|
|
16
|
+
("Finder", "com.apple.finder", ("finder",)),
|
|
17
|
+
("Terminal", "com.apple.Terminal", ("terminal",)),
|
|
18
|
+
("Visual Studio Code", "com.microsoft.VSCode", ("vs code", "vscode", "visual studio code", "code")),
|
|
19
|
+
("Safari", "com.apple.Safari", ("safari",)),
|
|
20
|
+
("Google Chrome", "com.google.Chrome", ("chrome", "google chrome")),
|
|
21
|
+
("System Settings", "com.apple.systempreferences", ("settings", "system settings")),
|
|
22
|
+
("Notes", "com.apple.Notes", ("notes",)),
|
|
23
|
+
("TextEdit", "com.apple.TextEdit", ("textedit", "text edit")),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def discover_applications(roots=APPLICATION_ROOTS) -> tuple:
|
|
28
|
+
"""Read app metadata in standard install folders; deduplicate by bundle identity."""
|
|
29
|
+
|
|
30
|
+
apps = {}
|
|
31
|
+
for root in roots:
|
|
32
|
+
for directory, folders, _ in os.walk(root):
|
|
33
|
+
bundles = sorted(name for name in folders if name.casefold().endswith(".app"))
|
|
34
|
+
folders[:] = sorted(name for name in folders if name not in bundles and not name.startswith(".")
|
|
35
|
+
and not name.casefold().endswith((".framework", ".bundle", ".xpc", ".plugin", ".appex", ".prefpane")))
|
|
36
|
+
for name in bundles:
|
|
37
|
+
path = Path(directory) / name
|
|
38
|
+
try:
|
|
39
|
+
with (path / "Contents/Info.plist").open("rb") as handle:
|
|
40
|
+
info = plistlib.load(handle)
|
|
41
|
+
except (OSError, ValueError, plistlib.InvalidFileException):
|
|
42
|
+
continue
|
|
43
|
+
if not isinstance(info, dict) or info.get("LSBackgroundOnly"):
|
|
44
|
+
continue
|
|
45
|
+
identifier = info.get("CFBundleIdentifier")
|
|
46
|
+
display = info.get("CFBundleDisplayName") or info.get("CFBundleName") or path.stem
|
|
47
|
+
if (not isinstance(identifier, str) or not re.fullmatch(r"[a-zA-Z0-9_-]+(?:\.[a-zA-Z0-9_-]+)+", identifier)
|
|
48
|
+
or len(identifier) > 240 or not isinstance(display, str) or not 1 <= len(display) <= 240):
|
|
49
|
+
continue
|
|
50
|
+
aliases = {display.casefold(), path.stem.casefold()}
|
|
51
|
+
aliases.update(alias for _, bundle, names in APP_ALIASES if bundle == identifier for alias in names)
|
|
52
|
+
if identifier in apps:
|
|
53
|
+
aliases.update(apps[identifier][2])
|
|
54
|
+
apps[identifier] = (display, identifier, tuple(sorted(aliases)))
|
|
55
|
+
return tuple(sorted(apps.values(), key=lambda item: (item[0].casefold(), item[1])))
|
app/cli.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Expose text-goal and live-speech entry points to Jev's bounded desktop path."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import queue
|
|
9
|
+
import select
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from threading import Event, Thread
|
|
14
|
+
from typing import TextIO
|
|
15
|
+
from time import monotonic
|
|
16
|
+
|
|
17
|
+
import ApplicationServices as AX
|
|
18
|
+
from AppKit import NSApplicationActivateIgnoringOtherApps
|
|
19
|
+
from Foundation import NSDate, NSRunLoop
|
|
20
|
+
from .models import ActionCandidate
|
|
21
|
+
from .config import user_data_directory
|
|
22
|
+
|
|
23
|
+
from .desktop import MacDesktop
|
|
24
|
+
from .runtime import run_goal
|
|
25
|
+
from .safety import requires_confirmation
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _parser() -> argparse.ArgumentParser:
|
|
29
|
+
"""Keep semantic inspection and app execution on the same command path."""
|
|
30
|
+
|
|
31
|
+
parser = argparse.ArgumentParser(description="Ask Jev for a grounded operation, target, and exact payload.")
|
|
32
|
+
parser.add_argument("goal", nargs="*", help="Text goal, such as 'switch to Finder'.")
|
|
33
|
+
parser.add_argument("--model", default="jev-latest")
|
|
34
|
+
parser.add_argument("--dry-run", action="store_true", help="Choose without executing.")
|
|
35
|
+
parser.add_argument("--yes", action="store_true", help="Skip routine confidence prompts; keep risky interactions and unfamiliar controls interactive.")
|
|
36
|
+
parser.add_argument('--auto-approve-basic', action='store_true', help='Skip confidence prompts for app opening/focus and supported web/Finder search; retain all native guards and other approvals.')
|
|
37
|
+
parser.add_argument("--request-accessibility", action="store_true", help="Ask macOS for Accessibility access for this CLI host.")
|
|
38
|
+
parser.add_argument("--listen", action="store_true", help="Continuously transcribe microphone utterances and send each final transcript to Jev.")
|
|
39
|
+
parser.add_argument("--transcribe-only", action="store_true", help="Listen and print transcripts without calling Jev or controlling the desktop.")
|
|
40
|
+
parser.add_argument("--stream-prefixes", action="store_true", help="With --listen, queue stable app-opening clauses before EOU; completed effects cannot be taken back.")
|
|
41
|
+
parser.add_argument("--silence-ms", type=int, default=650, help="Quiet audio needed to commit an utterance (default: 650 ms).")
|
|
42
|
+
parser.add_argument("--speech-threshold", type=float, default=0.012, help="RMS threshold for detecting speech (default: 0.012).")
|
|
43
|
+
parser.add_argument("--report", type=Path, default=user_data_directory() / "Reports" / "python-cli-last.json",
|
|
44
|
+
help="Save the session report to this path (default: %(default)s).")
|
|
45
|
+
return parser
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _save_report(path: Path, report: dict[str, object]) -> None:
|
|
49
|
+
"""Write one compact artifact atomically, including failure outcomes."""
|
|
50
|
+
|
|
51
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent, delete=False) as file:
|
|
53
|
+
temporary = Path(file.name)
|
|
54
|
+
json.dump(report, file, indent=2, sort_keys=True)
|
|
55
|
+
file.write("\n")
|
|
56
|
+
os.replace(temporary, path)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _confirm_action(desktop, prompt, *, cancel_event=None):
|
|
60
|
+
"""Allow voice cancellation while waiting, then restore the exact approved process."""
|
|
61
|
+
|
|
62
|
+
original = desktop.workspace.frontmostApplication()
|
|
63
|
+
if not original:
|
|
64
|
+
return False
|
|
65
|
+
pid = int(original.processIdentifier())
|
|
66
|
+
if cancel_event is None:
|
|
67
|
+
answer = input(prompt)
|
|
68
|
+
else:
|
|
69
|
+
print(prompt, end='', flush=True)
|
|
70
|
+
while not cancel_event.is_set():
|
|
71
|
+
if select.select([sys.stdin], [], [], 0.05)[0]:
|
|
72
|
+
answer = sys.stdin.readline()
|
|
73
|
+
break
|
|
74
|
+
else:
|
|
75
|
+
return False
|
|
76
|
+
if (cancel_event is not None and cancel_event.is_set()) or answer.strip().casefold() != 'y':
|
|
77
|
+
return False
|
|
78
|
+
current = desktop.workspace.frontmostApplication()
|
|
79
|
+
if current and int(current.processIdentifier()) == pid:
|
|
80
|
+
return True
|
|
81
|
+
if not original.activateWithOptions_(NSApplicationActivateIgnoringOtherApps):
|
|
82
|
+
return False
|
|
83
|
+
deadline = monotonic() + 2
|
|
84
|
+
while monotonic() < deadline:
|
|
85
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
86
|
+
return False
|
|
87
|
+
current = desktop.workspace.frontmostApplication()
|
|
88
|
+
if current and int(current.processIdentifier()) == pid:
|
|
89
|
+
return True
|
|
90
|
+
NSRunLoop.currentRunLoop().runUntilDate_(NSDate.dateWithTimeIntervalSinceNow_(0.05))
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _run_voice(
|
|
95
|
+
args: argparse.Namespace,
|
|
96
|
+
*,
|
|
97
|
+
desktop: object | None,
|
|
98
|
+
client: object | None,
|
|
99
|
+
output: TextIO,
|
|
100
|
+
speech_model: object | None,
|
|
101
|
+
audio_source_factory: object | None,
|
|
102
|
+
) -> int:
|
|
103
|
+
"""Run local ASR continuously and hand committed transcripts to one FIFO worker."""
|
|
104
|
+
|
|
105
|
+
from .speech_model import load_speech_model
|
|
106
|
+
from .speech_runtime import AudioInputError, StreamingCommands, run_live_asr
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
model = speech_model if speech_model is not None else load_speech_model()
|
|
110
|
+
except Exception as caught:
|
|
111
|
+
result = {"schema_version": 1, "mode": "voice", "runs": [], "transcripts": [], "error": str(caught)}
|
|
112
|
+
_save_report(args.report, result)
|
|
113
|
+
print(f"Voice setup error: {caught}", file=output)
|
|
114
|
+
return 1
|
|
115
|
+
target_desktop = desktop if desktop is not None else (None if args.transcribe_only else MacDesktop())
|
|
116
|
+
stopping = Event()
|
|
117
|
+
capture_done = Event()
|
|
118
|
+
approval_wait = Event()
|
|
119
|
+
committed = queue.Queue(maxsize=5)
|
|
120
|
+
transcripts: list[str] = []
|
|
121
|
+
runs: list[dict[str, object]] = []
|
|
122
|
+
pending: list[str] = []
|
|
123
|
+
rejected: list[str] = []
|
|
124
|
+
capture_error: list[str] = []
|
|
125
|
+
worker_error: list[str] = []
|
|
126
|
+
utterance_count = [0]
|
|
127
|
+
prefixes = []
|
|
128
|
+
|
|
129
|
+
def commit(text, prefix=False, intended_bundle=None):
|
|
130
|
+
"""Queue text and app context; partial clauses share the same five-goal bound."""
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
committed.put_nowait((text, prefix, intended_bundle))
|
|
134
|
+
if prefix:
|
|
135
|
+
prefixes.append(text)
|
|
136
|
+
print(f"\nQueued early: {text}", file=output, flush=True)
|
|
137
|
+
except queue.Full as error:
|
|
138
|
+
rejected.append(text)
|
|
139
|
+
stopping.set()
|
|
140
|
+
raise AudioInputError("The five-command goal queue is full; listening stopped and this instruction was rejected.") from error
|
|
141
|
+
|
|
142
|
+
stream = StreamingCommands(target_desktop.application_aliases(), commit) if args.stream_prefixes else None
|
|
143
|
+
|
|
144
|
+
def on_partial(text: str) -> None:
|
|
145
|
+
"""Show hypotheses and optionally queue a stable app clause."""
|
|
146
|
+
|
|
147
|
+
if not approval_wait.is_set():
|
|
148
|
+
print(f"\rPartial: {text}", end="", file=output, flush=True)
|
|
149
|
+
if stream is not None:
|
|
150
|
+
stream.partial(text)
|
|
151
|
+
|
|
152
|
+
def on_final(text: str) -> None:
|
|
153
|
+
"""Queue final text or its unconsumed suffix, rejecting overload explicitly."""
|
|
154
|
+
|
|
155
|
+
transcripts.append(text)
|
|
156
|
+
print(f"\nTranscript: {text}", file=output, flush=True)
|
|
157
|
+
if not args.transcribe_only and text.strip().casefold().strip('.!?') in {'stop listening', 'stop pilot', 'quit pilot'}:
|
|
158
|
+
stopping.set()
|
|
159
|
+
return
|
|
160
|
+
if args.transcribe_only:
|
|
161
|
+
return
|
|
162
|
+
if stream is not None:
|
|
163
|
+
stream.final(text)
|
|
164
|
+
else:
|
|
165
|
+
commit(text)
|
|
166
|
+
|
|
167
|
+
def capture() -> None:
|
|
168
|
+
"""Keep model decoding/capture off the AppKit execution thread."""
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
utterance_count[0] = run_live_asr(
|
|
172
|
+
model,
|
|
173
|
+
on_final,
|
|
174
|
+
on_partial=on_partial,
|
|
175
|
+
stop_event=stopping,
|
|
176
|
+
eou_silence_ms=args.silence_ms,
|
|
177
|
+
speech_threshold=args.speech_threshold,
|
|
178
|
+
**({"audio_source_factory": audio_source_factory} if audio_source_factory else {}),
|
|
179
|
+
)
|
|
180
|
+
except Exception as caught:
|
|
181
|
+
capture_error.append(str(caught) or f"ASR runtime failed ({type(caught).__name__}).")
|
|
182
|
+
stopping.set()
|
|
183
|
+
finally:
|
|
184
|
+
capture_done.set()
|
|
185
|
+
|
|
186
|
+
def approve(action: ActionCandidate) -> bool:
|
|
187
|
+
"""Skip routine confidence prompts with --yes; retain mandatory approvals."""
|
|
188
|
+
|
|
189
|
+
if args.yes and not requires_confirmation(action):
|
|
190
|
+
return True
|
|
191
|
+
if not sys.stdin.isatty():
|
|
192
|
+
return False
|
|
193
|
+
approval_wait.set()
|
|
194
|
+
print(f"Confirm spoken action: {action.description} [y/N] ", end="", file=output, flush=True)
|
|
195
|
+
try:
|
|
196
|
+
return _confirm_action(target_desktop, '', cancel_event=stopping)
|
|
197
|
+
finally:
|
|
198
|
+
approval_wait.clear()
|
|
199
|
+
|
|
200
|
+
def execute(item) -> None:
|
|
201
|
+
"""Run one queued transcript synchronously on the main/AppKit thread."""
|
|
202
|
+
|
|
203
|
+
goal, prefix, intended_bundle = item
|
|
204
|
+
result = run_goal(
|
|
205
|
+
goal,
|
|
206
|
+
target_desktop,
|
|
207
|
+
model=args.model,
|
|
208
|
+
dry_run=args.dry_run,
|
|
209
|
+
approve=approve,
|
|
210
|
+
client=client,
|
|
211
|
+
cancel_event=stopping,
|
|
212
|
+
allowed_operations={"OPEN_APP", "FOCUS_APP", "STOP"} if prefix else None,
|
|
213
|
+
intended_bundle=intended_bundle if not prefix else None,
|
|
214
|
+
stream_app=intended_bundle if prefix else None,
|
|
215
|
+
auto_approve_basic=args.auto_approve_basic,
|
|
216
|
+
)
|
|
217
|
+
entry = {"transcript": goal, "source": "stable_prefix" if prefix else "final", **result}
|
|
218
|
+
runs.append(entry)
|
|
219
|
+
print(json.dumps(entry, indent=2, sort_keys=True), file=output, flush=True)
|
|
220
|
+
if result["outcome"] not in {"completed", "dry_run"}:
|
|
221
|
+
worker_error.append(str(result.get("reason", result["outcome"])))
|
|
222
|
+
# Queued follow-ups may depend on this effect; only newly spoken goals resume.
|
|
223
|
+
while not committed.empty():
|
|
224
|
+
pending.append(committed.get_nowait()[0])
|
|
225
|
+
committed.task_done()
|
|
226
|
+
if prefix:
|
|
227
|
+
stopping.set()
|
|
228
|
+
elif not stopping.is_set():
|
|
229
|
+
print('Command stopped. Still listening for a new command.', file=output, flush=True)
|
|
230
|
+
else:
|
|
231
|
+
print('Ready for the next command.', file=output, flush=True)
|
|
232
|
+
_save_report(args.report, voice_report())
|
|
233
|
+
|
|
234
|
+
def voice_report():
|
|
235
|
+
"""Persist the session after each goal so continuous testing leaves evidence."""
|
|
236
|
+
|
|
237
|
+
result = {
|
|
238
|
+
'schema_version': 1, 'mode': 'transcribe_only' if args.transcribe_only else 'voice',
|
|
239
|
+
'model': getattr(model, 'model_id', 'mlx-community/nemotron-3.5-asr-streaming-0.6b-8bit'),
|
|
240
|
+
'transcripts': list(transcripts), 'committed_prefixes': list(prefixes),
|
|
241
|
+
'rejected_transcripts': list(rejected), 'runs': list(runs),
|
|
242
|
+
'pending_not_run': list(pending), 'utterances': max(utterance_count[0], len(transcripts)),
|
|
243
|
+
}
|
|
244
|
+
if worker_error:
|
|
245
|
+
result['worker_error'] = worker_error[-1]
|
|
246
|
+
return result
|
|
247
|
+
|
|
248
|
+
error = None
|
|
249
|
+
if args.transcribe_only:
|
|
250
|
+
try:
|
|
251
|
+
source_kwargs = {"eou_silence_ms": args.silence_ms, "speech_threshold": args.speech_threshold}
|
|
252
|
+
if audio_source_factory is not None:
|
|
253
|
+
source_kwargs["audio_source_factory"] = audio_source_factory
|
|
254
|
+
utterance_count[0] = run_live_asr(
|
|
255
|
+
model, on_final, on_partial=on_partial, stop_event=stopping, **source_kwargs
|
|
256
|
+
)
|
|
257
|
+
except KeyboardInterrupt:
|
|
258
|
+
stopping.set()
|
|
259
|
+
print("\nListening stopped; unfinished speech was discarded.", file=output)
|
|
260
|
+
except Exception as caught:
|
|
261
|
+
stopping.set()
|
|
262
|
+
error = str(caught) or f"Voice runtime failed ({type(caught).__name__})."
|
|
263
|
+
print(f"\nVoice error: {error}", file=output)
|
|
264
|
+
else:
|
|
265
|
+
capture_thread = Thread(target=capture, name="nemotron-streaming-asr", daemon=True)
|
|
266
|
+
capture_thread.start()
|
|
267
|
+
try:
|
|
268
|
+
while not stopping.is_set() and (not capture_done.is_set() or not committed.empty()):
|
|
269
|
+
try:
|
|
270
|
+
goal = committed.get(timeout=0.05)
|
|
271
|
+
except queue.Empty:
|
|
272
|
+
continue
|
|
273
|
+
try:
|
|
274
|
+
execute(goal)
|
|
275
|
+
if stopping.is_set():
|
|
276
|
+
break
|
|
277
|
+
finally:
|
|
278
|
+
committed.task_done()
|
|
279
|
+
except KeyboardInterrupt:
|
|
280
|
+
stopping.set()
|
|
281
|
+
print("\nListening stopped; unfinished speech was discarded.", file=output)
|
|
282
|
+
finally:
|
|
283
|
+
if stopping.is_set():
|
|
284
|
+
capture_thread.join()
|
|
285
|
+
elif capture_done.is_set():
|
|
286
|
+
capture_thread.join()
|
|
287
|
+
if capture_error:
|
|
288
|
+
error = capture_error[-1]
|
|
289
|
+
print(f"\nVoice error: {error}", file=output)
|
|
290
|
+
while not committed.empty():
|
|
291
|
+
pending.append(committed.get_nowait()[0])
|
|
292
|
+
committed.task_done()
|
|
293
|
+
|
|
294
|
+
result = voice_report()
|
|
295
|
+
if error:
|
|
296
|
+
result["error"] = error
|
|
297
|
+
if worker_error:
|
|
298
|
+
result["worker_error"] = worker_error[-1]
|
|
299
|
+
_save_report(args.report, result)
|
|
300
|
+
print(json.dumps({key: result[key] for key in ("mode", "utterances", "pending_not_run", "error", "worker_error") if key in result}, indent=2), file=output)
|
|
301
|
+
if error or worker_error or pending or rejected:
|
|
302
|
+
return 1
|
|
303
|
+
return 0
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def main(
|
|
307
|
+
argv: list[str] | None = None,
|
|
308
|
+
*,
|
|
309
|
+
desktop: object | None = None,
|
|
310
|
+
client: object | None = None,
|
|
311
|
+
output: TextIO = sys.stdout,
|
|
312
|
+
speech_model: object | None = None,
|
|
313
|
+
audio_source_factory: object | None = None,
|
|
314
|
+
) -> int:
|
|
315
|
+
"""Run one text goal or an explicit live voice session; typed mode stays unchanged."""
|
|
316
|
+
|
|
317
|
+
args = _parser().parse_args(argv)
|
|
318
|
+
if args.stream_prefixes and (not args.listen or args.transcribe_only):
|
|
319
|
+
_parser().error("--stream-prefixes requires --listen without --transcribe-only")
|
|
320
|
+
if args.request_accessibility:
|
|
321
|
+
trusted = bool(AX.AXIsProcessTrustedWithOptions({AX.kAXTrustedCheckOptionPrompt: True}))
|
|
322
|
+
print(json.dumps({"python_executable": sys.executable, "accessibility_trusted": trusted}, indent=2), file=output)
|
|
323
|
+
return 0 if trusted else 1
|
|
324
|
+
if args.listen or args.transcribe_only:
|
|
325
|
+
if args.goal:
|
|
326
|
+
_parser().error("do not combine a positional text goal with microphone mode")
|
|
327
|
+
return _run_voice(
|
|
328
|
+
args, desktop=desktop, client=client, output=output,
|
|
329
|
+
speech_model=speech_model, audio_source_factory=audio_source_factory,
|
|
330
|
+
)
|
|
331
|
+
if not args.goal:
|
|
332
|
+
_parser().error("a text goal is required unless --request-accessibility is used")
|
|
333
|
+
goal = " ".join(args.goal)
|
|
334
|
+
target_desktop = desktop if desktop is not None else MacDesktop()
|
|
335
|
+
|
|
336
|
+
def approve(action: ActionCandidate) -> bool:
|
|
337
|
+
"""Apply the same routine/risky approval boundary as listening mode."""
|
|
338
|
+
|
|
339
|
+
if args.yes and not requires_confirmation(action):
|
|
340
|
+
return True
|
|
341
|
+
if not sys.stdin.isatty():
|
|
342
|
+
return False
|
|
343
|
+
if action.kind == "TYPE_TEXT":
|
|
344
|
+
# Show the exact locally resolved payload before low-confidence approval.
|
|
345
|
+
print(f"Text: {action.parameters['text']!r}")
|
|
346
|
+
return _confirm_action(target_desktop, f"Approve {action.description} [y/N] ")
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
report = run_goal(
|
|
350
|
+
goal,
|
|
351
|
+
target_desktop,
|
|
352
|
+
model=args.model,
|
|
353
|
+
dry_run=args.dry_run,
|
|
354
|
+
approve=approve,
|
|
355
|
+
client=client,
|
|
356
|
+
auto_approve_basic=args.auto_approve_basic,
|
|
357
|
+
)
|
|
358
|
+
except KeyboardInterrupt:
|
|
359
|
+
report = {"schema_version": 3, "goal": goal, "outcome": "cancelled", "reason": "Interrupted; an already sent effect cannot be undone."}
|
|
360
|
+
_save_report(args.report, report)
|
|
361
|
+
print(json.dumps(report, indent=2, sort_keys=True), file=output)
|
|
362
|
+
return 0 if report["outcome"] in {"completed", "dry_run"} else 1
|
app/config.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Resolve user-data paths and load the TypeSafe key without exposing secrets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from dotenv import dotenv_values
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConfigurationError(RuntimeError):
|
|
12
|
+
"""Report missing or unusable local CLI configuration."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def repository_root() -> Path:
|
|
16
|
+
"""Return the repository root from this package's stable location."""
|
|
17
|
+
|
|
18
|
+
return Path(__file__).resolve().parents[1]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def user_data_directory() -> Path:
|
|
22
|
+
"""Keep installed configuration and reports outside the working directory."""
|
|
23
|
+
|
|
24
|
+
return Path.home() / "Library" / "Application Support" / "Jev Pilot"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_api_key(root: Path | None = None) -> str:
|
|
28
|
+
"""Load the key from the environment, checkout .env, then user .env."""
|
|
29
|
+
|
|
30
|
+
# Environment variables win so CI and temporary overrides need no file change.
|
|
31
|
+
environment_key = os.environ.get("TYPESAFE_API_KEY", "").strip()
|
|
32
|
+
if environment_key:
|
|
33
|
+
return environment_key
|
|
34
|
+
|
|
35
|
+
paths = [user_data_directory() / ".env"]
|
|
36
|
+
source = root if root is not None else repository_root()
|
|
37
|
+
# A wheel's site-packages parent is not a checkout or a configuration source.
|
|
38
|
+
if root is not None or (source / "pyproject.toml").is_file():
|
|
39
|
+
paths.insert(0, source / ".env")
|
|
40
|
+
for path in paths:
|
|
41
|
+
values = dotenv_values(path)
|
|
42
|
+
file_key = (values.get("TYPESAFE_API_KEY") or "").strip()
|
|
43
|
+
if file_key:
|
|
44
|
+
return file_key
|
|
45
|
+
|
|
46
|
+
raise ConfigurationError(
|
|
47
|
+
f"TYPESAFE_API_KEY is missing. Set it in the environment or {user_data_directory() / '.env'} "
|
|
48
|
+
"(or the source checkout's .env file)."
|
|
49
|
+
)
|