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 ADDED
@@ -0,0 +1 @@
1
+ """Python-first Jev Pilot runtime."""
app/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Run the package with `python -m app`."""
2
+
3
+ from .cli import main
4
+
5
+ raise SystemExit(main())
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
+ )