feat: migrate Mac mini services into Reyna CLI
This commit is contained in:
@@ -0,0 +1,446 @@
|
|||||||
|
# Simple Signed Python Launcher — Future Hardening Plan
|
||||||
|
|
||||||
|
> **Selected implementation (2026-08-15):** The user chose the VoiceAgent-style design now: the manually Xcode-signed `Reyna CLI.app` accepts `--python`, reads `REYNA_CLI_DIR` / `REYNA_CLI_PYTHON` from environment or `~/.reyna-cli.env` / `~/.config/reyna-cli/env`, and runs `python -m reyna_cli.cli` with forwarded arguments. Mutable Python changes therefore take effect without another native rebuild or signing pass. The launcher removes inherited Python interpreter overrides (`PYTHONPATH`, `PYTHONHOME`, `VIRTUAL_ENV`) so Hermes cannot contaminate the selected project venv.
|
||||||
|
>
|
||||||
|
> **Deferred hardening:** The allowlist, owner-safe runtime validation, bounded JSON protocol, and native operation routing described below are deliberately retained as the future hardening design. They are not part of the current simple launcher and must not be represented as its present security boundary.
|
||||||
|
|
||||||
|
> **For Hermes:** Use `subagent-driven-development` skill to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Let the stable, Apple-signed `Reyna CLI.app` launch a narrowly allowlisted set of changeable Reyna CLI Python operations, so approved macOS privacy access remains attributable to `com.reyna.cli.privacy-host` while Python logic can be updated without rebuilding or re-signing the app.
|
||||||
|
|
||||||
|
**Architecture:** Reuse the existing signed `Reyna CLI.app` / `ReynaCLIHost` as the only privacy identity; do **not** create a second app or turn the AF_UNIX host into a LAN service. Add an exact-operation Swift process runner to the app and a Python adapter that requests only named operations through the app. The Swift layer accepts neither an arbitrary executable nor arbitrary script/module/arguments; it validates an owner-safe runtime configuration, launches a fixed Python module with a bounded JSON request on stdin, and returns one bounded JSON response. Existing native Calendar, Contacts, and Reminders operations stay in Swift and retain their current owner-only socket boundary.
|
||||||
|
|
||||||
|
**Tech Stack:** Swift 6 / Xcode signed macOS app, Foundation `Process`, Python 3.14 / Typer / pytest / uv, JSON-lines protocol, existing Xcode Automatic Signing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Evidence and design decisions
|
||||||
|
|
||||||
|
### What VoiceAgent proved
|
||||||
|
|
||||||
|
- `/Users/adolforeyna/VoiceAgent/swift/VoiceAgentLauncher.swift:35-54` chooses a workspace and Python interpreter from user configuration, then launches `app_main.py` through Foundation `Process` (`:67-80`).
|
||||||
|
- `/Users/adolforeyna/VoiceAgent/build_app.sh:57-83` deliberately skips rebuilding/re-signing when native Swift sources are unchanged; Python files are copied independently. Its `AGENTS.md` describes the intended result: Python edits take effect without a new permission request.
|
||||||
|
- The mechanism is relevant, but its implementation is **not** a safe drop-in: it permits project/Python selection from environment and three env files, then runs an arbitrary workspace entry point. That is acceptable for a personal app launcher, but too broad for Reyna CLI’s signed privacy host.
|
||||||
|
|
||||||
|
### Reyna CLI baseline
|
||||||
|
|
||||||
|
- The existing native identity is already the right foundation: `native/ReynaCLIHost/ReynaCLIHost/Info.plist` declares bundle ID `com.reyna.cli.privacy-host`, and the local bundle verification returned that ID with TeamIdentifier `RHUM5U925W`.
|
||||||
|
- `src/reyna_cli/privacy_host.py:158-176` launches the signed bundle through a per-user LaunchAgent, while `Sources/ReynaCLIHostCore/AppEntry.swift:7-18` currently provides only owner-only socket or stdin JSON-lines modes.
|
||||||
|
- Calendar, Contacts, and Reminders are intentionally native Swift providers. `docs/remaining-coverage-matrix.md:5-20` explicitly keeps direct speech/media paths outside the privacy host; this plan changes that only after a real permission-attribution spike proves the intended path.
|
||||||
|
- Current file/live speech code recompiles source strings to hash-named binaries under `~/Library/Application Support/reyna-cli/speech/` (`src/reyna_cli/speech_execution.py:14-113`, `speech_live.py:17-158`). That defeats a stable executable identity and is the first migration target.
|
||||||
|
|
||||||
|
### Non-goals and invariants
|
||||||
|
|
||||||
|
- No generic “run Python,” shell, script path, module, arbitrary environment, TCP, or LAN RPC interface.
|
||||||
|
- Superseded on 2026-08-15: Apple Notes is now a mutable Python-only CLI route using fixed JXA and the existing Automation approval; it does not modify the signed native host.
|
||||||
|
- No removal/cutover of MacMiniMCP.
|
||||||
|
- No automatic Privacy & Security click. The one live TCC grant is a user-facing, explicit acceptance step.
|
||||||
|
- Never rebuild, replace, or re-sign the installed production app merely because Python changed. A native Swift / plist / entitlement change is a separately approved release.
|
||||||
|
|
||||||
|
## Threat model / chosen boundary
|
||||||
|
|
||||||
|
The mutable Python source is intentionally trusted **as the local user’s Reyna CLI logic**. It can evolve without invalidating the `.app` signature. Therefore the runner must prevent other callers from converting the signed host into a generic privileged code launcher:
|
||||||
|
|
||||||
|
1. The native app accepts a constant allowlist of operation names only (initially `speech.transcribe_file` and `speech.live_session`; add later operations deliberately).
|
||||||
|
2. It selects one fixed Python module for each operation. It never accepts `--script`, `-c`, a module name, an executable path, or shell syntax from the CLI/RPC payload.
|
||||||
|
3. Runtime configuration contains only canonical absolute paths for the Reyna CLI repo and venv Python; it is owner-owned, non-symlinked, and not group/other writable. Environment input cannot override it.
|
||||||
|
4. The request is bounded JSON on stdin and the response is bounded JSON on stdout. Arguments are schema-validated in both Swift and Python; stderr is bounded diagnostic text and never leaks credentials.
|
||||||
|
5. The app only exposes this runner as a foreground/on-demand executable mode, not through the persistent AF_UNIX service. The socket server continues to dispatch Swift-native privacy operations only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 0: Establish a clean, reproducible baseline and canonical checkout
|
||||||
|
|
||||||
|
**Objective:** Record the baseline and remove the current test ambiguity before changing behavior.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `docs/plans/2026-08-15-stable-signed-python-permission-runner.md` (this plan)
|
||||||
|
- Create: `docs/permission-runner-baseline.md`
|
||||||
|
- Test: existing Python and Swift suites (no product-code changes)
|
||||||
|
|
||||||
|
**Step 1: Identify the canonical repository path**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd /Users/adolforeyna/Projects/reyna-cli
|
||||||
|
pwd -P
|
||||||
|
git rev-parse --show-toplevel
|
||||||
|
git status --short
|
||||||
|
```
|
||||||
|
|
||||||
|
Record the physical path and preserve the existing unrelated working-tree changes. Do not reset, clean, or stage them.
|
||||||
|
|
||||||
|
**Step 2: Reproduce the current verification blockers**
|
||||||
|
|
||||||
|
Run the Python suite with the project interpreter isolated from Hermes’ environment, then run the Swift suite from the same physical checkout:
|
||||||
|
```bash
|
||||||
|
env -i HOME="$HOME" PATH="/opt/homebrew/bin:/usr/bin:/bin" \
|
||||||
|
/Users/adolforeyna/Projects/reyna-cli/.venv/bin/python -m pytest -q
|
||||||
|
cd /Users/adolforeyna/Projects/reyna-cli/native/ReynaCLIHost && swift test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected current issues to document, not hide:
|
||||||
|
- Python’s `.venv` imports Hermes’ Python 3.11 `pydantic_core` under a Python 3.14 executable.
|
||||||
|
- Swift has duplicate module-cache paths because both `/Users/adolforeyna/Projects/reyna-cli` and `/Users/adolforeyna/Projects/platform/reyna-cli` are being used.
|
||||||
|
|
||||||
|
**Step 3: Repair only the development environment after identifying its owner**
|
||||||
|
|
||||||
|
Use the canonical path exclusively; resolve the venv/module-cache issue without editing application logic. Re-run the same commands and record the exact green baseline. If fixing it would alter a shared environment or discard user work, stop and request scope confirmation.
|
||||||
|
|
||||||
|
**Step 4: Commit the baseline note only after review**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/permission-runner-baseline.md docs/plans/2026-08-15-stable-signed-python-permission-runner.md
|
||||||
|
git diff --cached --check
|
||||||
|
git commit -m "docs: record signed Python runner baseline"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Specify the fixed runner protocol and runtime configuration (TDD)
|
||||||
|
|
||||||
|
**Objective:** Define a testable contract that makes generic code execution impossible.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/reyna_cli/permission_runner.py`
|
||||||
|
- Create: `tests/test_permission_runner.py`
|
||||||
|
- Create: `native/ReynaCLIHost/Sources/ReynaCLIHostCore/PythonRunnerProtocol.swift`
|
||||||
|
- Create: `native/ReynaCLIHost/Tests/ReynaCLIHostTests/PythonRunnerProtocolTests.swift`
|
||||||
|
|
||||||
|
**Step 1: Write failing Python contract tests**
|
||||||
|
|
||||||
|
Cover:
|
||||||
|
```python
|
||||||
|
def test_operation_allowlist_has_only_named_operations(): ...
|
||||||
|
def test_runtime_config_rejects_relative_symlink_or_group_writable_paths(tmp_path): ...
|
||||||
|
def test_request_rejects_script_module_shell_and_unknown_operation(): ...
|
||||||
|
def test_payload_is_json_object_and_is_bounded(): ...
|
||||||
|
def test_config_does_not_read_environment_overrides(monkeypatch): ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Write failing Swift protocol tests**
|
||||||
|
|
||||||
|
Cover exact operation matching, max request/response size, invalid JSON, extra command-line flags, and a rejection result that contains no runtime secrets.
|
||||||
|
|
||||||
|
**Step 3: Implement the minimal shared contract**
|
||||||
|
|
||||||
|
Use a stable versioned schema:
|
||||||
|
```json
|
||||||
|
{"version":1,"id":"uuid","operation":"speech.transcribe_file","arguments":{"audio_path":"/absolute/path.wav","locale":"en-US"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use a Swift `enum PythonOperation: String, CaseIterable` and a Python `Final[frozenset[str]]`; do not duplicate untested string maps across call sites. Put the fixed module mapping in Swift, for example:
|
||||||
|
```swift
|
||||||
|
case .speechTranscribeFile: return "reyna_cli.permission_runner"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Define runtime configuration**
|
||||||
|
|
||||||
|
Use one fixed per-user file:
|
||||||
|
`~/Library/Application Support/reyna-cli/privacy/python-runtime.json`
|
||||||
|
|
||||||
|
It contains only:
|
||||||
|
```json
|
||||||
|
{"version":1,"repo_root":"/absolute/canonical/reyna-cli","python":"/absolute/canonical/reyna-cli/.venv/bin/python"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Validate its path components with the same owner/no-symlink discipline used for the native socket. Reject a missing, non-regular, symlinked, foreign-owned, or group/other-writable config, repo, Python executable, and source package path. The config is not an environment file and has no token fields.
|
||||||
|
|
||||||
|
**Step 5: Verify**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
env -u VIRTUAL_ENV uv run pytest tests/test_permission_runner.py -v
|
||||||
|
cd native/ReynaCLIHost && swift test --filter PythonRunnerProtocolTests
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/reyna_cli/permission_runner.py tests/test_permission_runner.py \
|
||||||
|
native/ReynaCLIHost/Sources/ReynaCLIHostCore/PythonRunnerProtocol.swift \
|
||||||
|
native/ReynaCLIHost/Tests/ReynaCLIHostTests/PythonRunnerProtocolTests.swift
|
||||||
|
git diff --cached --check
|
||||||
|
git commit -m "feat: define restricted signed Python runner protocol"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Add the signed Swift process runner without changing the socket host
|
||||||
|
|
||||||
|
**Objective:** Make `ReynaCLIHost` the parent process for one allowlisted Python operation and preserve the existing native socket behavior byte-for-byte.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `native/ReynaCLIHost/Sources/ReynaCLIHostCore/PythonOperationRunner.swift`
|
||||||
|
- Create: `native/ReynaCLIHost/Tests/ReynaCLIHostTests/PythonOperationRunnerTests.swift`
|
||||||
|
- Modify: `native/ReynaCLIHost/Sources/ReynaCLIHostCore/AppEntry.swift`
|
||||||
|
- Modify: `native/ReynaCLIHost/Package.swift`
|
||||||
|
- Modify: `native/ReynaCLIHost/ReynaCLIHost.xcodeproj/project.pbxproj`
|
||||||
|
- Test: `native/ReynaCLIHost/Tests/ReynaCLIHostTests/HostIntegrationTests.swift`
|
||||||
|
|
||||||
|
**Step 1: Write failing runner tests**
|
||||||
|
|
||||||
|
Use an injected process factory. Assert:
|
||||||
|
- command is exactly `[configuredPython, "-m", "reyna_cli.permission_runner", "--operation", exactOperation]`;
|
||||||
|
- working directory is the validated repo root;
|
||||||
|
- only a minimal fixed environment is passed (`HOME`, `PATH`, `LANG`, `LC_*` as explicitly required), not the parent process environment;
|
||||||
|
- JSON is written once to stdin; stdout is a single bounded JSON object; nonzero exit, timeout, malformed response, or oversized output becomes a safe structured error;
|
||||||
|
- `--socket` never dispatches to this runner.
|
||||||
|
|
||||||
|
**Step 2: Extend `runReynaCLIHost` with one exact mode**
|
||||||
|
|
||||||
|
Add only:
|
||||||
|
```text
|
||||||
|
--python-operation <operation>
|
||||||
|
```
|
||||||
|
|
||||||
|
Reject duplicate flags, positional leftovers, `--socket` combinations, unknown operations, and absent input. Keep the existing `--socket <path>` and stdin native JSON-lines behavior unchanged.
|
||||||
|
|
||||||
|
**Step 3: Update both build systems**
|
||||||
|
|
||||||
|
Add new Swift files to `Package.swift` automatically by target path and add file/build references in the Xcode project. Do not alter bundle identifier, signing configuration, Info.plist privacy keys, or the existing EventKit/Contacts linkage.
|
||||||
|
|
||||||
|
**Step 4: Verify**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd native/ReynaCLIHost
|
||||||
|
swift test --filter PythonOperationRunnerTests
|
||||||
|
swift test --filter HostIntegrationTests
|
||||||
|
xcodebuild -project ReynaCLIHost.xcodeproj -scheme 'Reyna CLI' \
|
||||||
|
-configuration Release CODE_SIGNING_ALLOWED=NO build
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add native/ReynaCLIHost
|
||||||
|
git diff --cached --check
|
||||||
|
git commit -m "feat: add allowlisted Python operation runner to signed host"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Implement the mutable Python operation adapter (TDD)
|
||||||
|
|
||||||
|
**Objective:** Receive the bounded request, validate it again, and execute only the named Reyna CLI operation.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/reyna_cli/permission_runner.py`
|
||||||
|
- Modify: `tests/test_permission_runner.py`
|
||||||
|
- Create: `tests/test_permission_runner_integration.py`
|
||||||
|
|
||||||
|
**Step 1: Write failing adapter tests**
|
||||||
|
|
||||||
|
Tests must prove that the adapter:
|
||||||
|
- reads exactly one request object from stdin and returns one JSON object;
|
||||||
|
- rejects an extra line, unsupported schema version, unknown operation, non-absolute/unreadable input path, missing locale, and unknown keys;
|
||||||
|
- never invokes `shell=True`, `os.system`, `exec`, or dynamic import based on input;
|
||||||
|
- strips/truncates child diagnostics and never returns environment values;
|
||||||
|
- invokes an internal function table keyed by constant operation names.
|
||||||
|
|
||||||
|
**Step 2: Implement operation handlers as ordinary Python functions**
|
||||||
|
|
||||||
|
Start with only the two speech operations. Each operation receives a typed dataclass, not a raw dict. Return an operation-specific typed response. Keep any long-lived live-transcription child owned by the signed host invocation; do not daemonize it through the privacy socket.
|
||||||
|
|
||||||
|
**Step 3: Add an end-to-end test harness**
|
||||||
|
|
||||||
|
Use a temporary validated repo/config plus a harmless Python fixture module or injected runner seam. Verify host → Python request/response shape, negative paths, and that the host’s executable is the immediate parent. This is a structural test, not evidence of TCC attribution.
|
||||||
|
|
||||||
|
**Step 4: Verify**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
env -u VIRTUAL_ENV uv run pytest tests/test_permission_runner.py tests/test_permission_runner_integration.py -v
|
||||||
|
cd native/ReynaCLIHost && swift test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/reyna_cli/permission_runner.py tests/test_permission_runner.py tests/test_permission_runner_integration.py
|
||||||
|
git diff --cached --check
|
||||||
|
git commit -m "feat: add validated mutable Python operation adapter"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Add the Reyna CLI client route and preserve compatibility (TDD)
|
||||||
|
|
||||||
|
**Objective:** Route supported CLI operations through the signed host without making the host a generic subprocess service.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/reyna_cli/signed_runner_client.py`
|
||||||
|
- Create: `tests/test_signed_runner_client.py`
|
||||||
|
- Modify: `src/reyna_cli/cli.py`
|
||||||
|
- Modify: `src/reyna_cli/privacy_host.py`
|
||||||
|
- Modify: `tests/test_reyna.py`
|
||||||
|
- Modify: `tests/test_privacy_host.py`
|
||||||
|
|
||||||
|
**Step 1: Write failing client tests**
|
||||||
|
|
||||||
|
Test the client builds the exact signed-app executable path from `app_bundle_executable_path()`, sends the versioned JSON request over stdin, enforces timeouts/output caps, and fails closed if `validate_app_bundle()` is not signed/valid. Test that no app validation output leaks signing identity data.
|
||||||
|
|
||||||
|
**Step 2: Implement `SignedRunnerClient`**
|
||||||
|
|
||||||
|
Use direct argument vectors:
|
||||||
|
```python
|
||||||
|
[app_executable, "--python-operation", operation]
|
||||||
|
```
|
||||||
|
Pass request JSON via `input=...`; never encode it in a shell command, URL, environment variable, or temporary world-readable file.
|
||||||
|
|
||||||
|
**Step 3: Wire explicit speech commands behind a feature gate**
|
||||||
|
|
||||||
|
Add a `--via-signed-host` internal/experimental option first, defaulting to the old direct code until the live TCC spike passes. The default does not change in this task. This provides an easy rollback and avoids claiming migration before permission identity is verified.
|
||||||
|
|
||||||
|
**Step 4: Verify**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
env -u VIRTUAL_ENV uv run pytest tests/test_signed_runner_client.py tests/test_reyna.py tests/test_privacy_host.py -q
|
||||||
|
env -u VIRTUAL_ENV uv run reyna-cli local-services speech transcribe-file --help
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/reyna_cli/signed_runner_client.py src/reyna_cli/cli.py src/reyna_cli/privacy_host.py tests/
|
||||||
|
git diff --cached --check
|
||||||
|
git commit -m "feat: route opt-in speech calls through signed runner"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Replace runtime Swift compilation only after the live permission spike passes
|
||||||
|
|
||||||
|
**Objective:** Eliminate hash-named runtime binaries for migrated speech operations while keeping mutable Python orchestration.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/reyna_cli/speech_execution.py`
|
||||||
|
- Modify: `src/reyna_cli/speech_live.py`
|
||||||
|
- Modify: `src/reyna_cli/permission_runner.py`
|
||||||
|
- Modify: `tests/test_media_execution.py`
|
||||||
|
- Modify: `tests/test_permission_runner.py`
|
||||||
|
- Modify: `docs/remaining-coverage-matrix.md`
|
||||||
|
|
||||||
|
**Step 1: Write red migration tests**
|
||||||
|
|
||||||
|
Assert signed-runner paths do not call `swiftc`, do not create hash-named binaries, and return `source: "reyna_cli_signed_host"`. Preserve a direct-path regression test until cutover approval.
|
||||||
|
|
||||||
|
**Step 2: Choose the minimal proven execution shape**
|
||||||
|
|
||||||
|
After the spike, choose one of these based on real evidence:
|
||||||
|
- **Preferred:** move the SpeechAnalyzer implementation into a signed Swift operation in `ReynaCLIHost`; Python remains mutable validation/orchestration around it.
|
||||||
|
- **Only if the spike proves TCC attribution through the Python child:** retain the Python operation runner and invoke the required trusted helper under the signed host, with no runtime compilation.
|
||||||
|
|
||||||
|
Do not infer that a child process inherits usable TCC authorization merely because its parent is bundle-associated. The live prompt and post-edit run are the acceptance evidence.
|
||||||
|
|
||||||
|
**Step 3: Keep live-session ownership clear**
|
||||||
|
|
||||||
|
The client process owns the signed-host child for the duration of `speech.live_session`; closure sends EOF and waits. It must not leave an orphan, long-running process or open listening endpoint.
|
||||||
|
|
||||||
|
**Step 4: Verify**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
env -u VIRTUAL_ENV uv run pytest tests/test_media_execution.py tests/test_permission_runner.py -q
|
||||||
|
cd native/ReynaCLIHost && swift test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/reyna_cli/speech_execution.py src/reyna_cli/speech_live.py src/reyna_cli/permission_runner.py tests/test_media_execution.py tests/test_permission_runner.py docs/remaining-coverage-matrix.md
|
||||||
|
git diff --cached --check
|
||||||
|
git commit -m "refactor: run speech through stable signed host"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Build, sign, and run the one-time TCC acceptance test
|
||||||
|
|
||||||
|
**Objective:** Prove the real macOS behavior that the design is intended to preserve.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/remaining-coverage-matrix.md`
|
||||||
|
- Create: `docs/permission-runner-verification.md`
|
||||||
|
|
||||||
|
**Step 1: Prepare before requesting permission**
|
||||||
|
|
||||||
|
Run all automated checks first. Build the app with its existing Xcode Automatic Signing configuration. Validate it using the existing `reyna-cli privacy-host status` / app-bundle validation path and confirm it is not ad-hoc.
|
||||||
|
|
||||||
|
**Step 2: Ask the user before the live privileged run**
|
||||||
|
|
||||||
|
Explain in one sentence: “Reyna CLI will request Speech Recognition access under its own signed app identity; no audio is uploaded.” Wait for explicit approval before launching the operation that triggers the OS prompt.
|
||||||
|
|
||||||
|
**Step 3: Grant once and verify attribution**
|
||||||
|
|
||||||
|
After the user grants it, run a known local fixture through `--via-signed-host`. Record only pass/fail and visible app identity, not TCC database contents, secrets, or unrelated privacy records.
|
||||||
|
|
||||||
|
**Step 4: Verify the promised update behavior**
|
||||||
|
|
||||||
|
1. Change a harmless Python response marker (not Swift, plist, bundle ID, entitlement, or signing setting).
|
||||||
|
2. Re-run the same signed-host operation.
|
||||||
|
3. Confirm the changed Python behavior is present and macOS does **not** request permission again.
|
||||||
|
4. Revert the harmless marker.
|
||||||
|
|
||||||
|
This is the key acceptance criterion. If it fails, revert the feature gate to direct mode and document the result; do not repeatedly prompt or rebuild blindly.
|
||||||
|
|
||||||
|
**Step 5: Commit evidence/docs**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/remaining-coverage-matrix.md docs/permission-runner-verification.md
|
||||||
|
git diff --cached --check
|
||||||
|
git commit -m "docs: verify signed Python permission runner"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Controlled default cutover and release verification
|
||||||
|
|
||||||
|
**Objective:** Make signed-host execution the default only after the verified acceptance test and preserve rollback.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/reyna_cli/cli.py`
|
||||||
|
- Modify: `src/reyna_cli/signed_runner_client.py`
|
||||||
|
- Modify: `tests/test_reyna.py`
|
||||||
|
- Modify: `README.md`
|
||||||
|
- Modify: `docs/remaining-coverage-matrix.md`
|
||||||
|
|
||||||
|
**Step 1: Write red default/rollback tests**
|
||||||
|
|
||||||
|
Verify supported speech commands default to the signed route when the signed bundle validates, but `--direct` remains a documented temporary fallback. If the signed bundle is missing, ad-hoc, invalid, or operation unsupported, the command fails plainly; it does not silently use an untrusted generic subprocess route.
|
||||||
|
|
||||||
|
**Step 2: Switch the default and document boundary**
|
||||||
|
|
||||||
|
Document which operations are: (a) signed privacy-host runner, (b) direct local CLI, and (c) remote service. State that Python logic may change independently, but native host changes require a signed app release and may require a new permission review.
|
||||||
|
|
||||||
|
**Step 3: Full verification**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
env -u VIRTUAL_ENV uv run pytest -q
|
||||||
|
cd native/ReynaCLIHost && swift test
|
||||||
|
cd /Users/adolforeyna/Projects/reyna-cli
|
||||||
|
xcodebuild -project native/ReynaCLIHost/ReynaCLIHost.xcodeproj -scheme 'Reyna CLI' \
|
||||||
|
-configuration Release CODE_SIGNING_ALLOWED=NO build
|
||||||
|
env -u VIRTUAL_ENV uv run reyna-cli privacy-host status --json
|
||||||
|
env -u VIRTUAL_ENV uv run reyna-cli local-services speech transcribe-file --help
|
||||||
|
git diff --check
|
||||||
|
git status --short
|
||||||
|
```
|
||||||
|
|
||||||
|
Then repeat the signed live smoke test only with the user’s prior approval and confirm no new prompt occurs after a Python-only edit.
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/reyna_cli/cli.py src/reyna_cli/signed_runner_client.py tests/test_reyna.py README.md docs/remaining-coverage-matrix.md
|
||||||
|
git diff --cached --check
|
||||||
|
git commit -m "feat: default speech execution to stable signed host"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Release acceptance checklist
|
||||||
|
|
||||||
|
- [ ] Native host has the existing stable bundle ID and a non-ad-hoc Xcode signature.
|
||||||
|
- [ ] There is no generic Python/script/shell execution interface.
|
||||||
|
- [ ] Socket IPC remains owner-only and does not dispatch Python operations.
|
||||||
|
- [ ] Calendar, Contacts, Reminders native-host regressions and direct Notes CLI regressions remain green.
|
||||||
|
- [ ] A Python-only logic edit changes behavior without rebuilding/re-signing the app.
|
||||||
|
- [ ] The post-edit operation succeeds without a second TCC prompt.
|
||||||
|
- [ ] No new daemon, TCP listener, copied credential, or unbounded log was introduced.
|
||||||
|
- [ ] Direct fallback remains available until the owner explicitly approves its retirement.
|
||||||
@@ -13,17 +13,20 @@ The host remains owner-only AF_UNIX IPC. It does not expose TCP or a LAN service
|
|||||||
|
|
||||||
## Direct local-service ownership (no MacMiniMCP)
|
## Direct local-service ownership (no MacMiniMCP)
|
||||||
|
|
||||||
- Speech / Kokoro / Voicebox / image / Apple-LLM wrappers are configuration and offline-status paths only. They do not change service lifecycle, synthesize audio, perform network work, or disclose configuration-sensitive values merely for status.
|
- Speech / unified voice generation (Kokoro, Kyutai Pocket JV, and Qwen3-TTS JV), Voicebox, image, and Apple-LLM execution are available through direct Reyna CLI paths. These remain outside the signed privacy host: they use local subprocesses, loopback media daemons, or explicitly configured external APIs.
|
||||||
- Deco retains its direct client path.
|
- Speech file transcription uses a Reyna CLI-owned cached SpeechAnalyzer helper.
|
||||||
|
- Speech live transcription uses a Reyna CLI-owned persistent pipe session and warmed helper process.
|
||||||
|
- Kokoro uses the existing warm `ksay` daemon; Pocket uses the local Kyutai runtime with the verified `jv_pocket.pt` state; Qwen3-TTS uses the local MLX JV clone; Voicebox uses its local generation API; Codex/Gemini image generation runs directly from Reyna CLI.
|
||||||
|
- The privacy host remains limited to owner-only macOS privacy operations.
|
||||||
|
|
||||||
## Deliberately deferred
|
## Deliberately deferred
|
||||||
|
|
||||||
| Integration | Status | Boundary |
|
| Integration | Status | Boundary |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Apple Notes** | **Deferred — legacy untouched** | Reyna CLI has no Apple Notes capability, no Apple Events usage declaration, no AppKit Automation helper, and will not request Notes permission. Legacy MacMiniMCP Notes/JXA handling remains unchanged and is not part of this consolidation. |
|
| **Apple Notes** | **Direct mutable Reyna CLI** | `reyna-cli notes` (and compatibility alias `macmini notes`) uses fixed JXA scripts through `/usr/bin/osascript`; note fields are JSON arguments, never script interpolation. This does not modify the stable Swift host, plist, entitlements, or signed bundle. It uses the existing Apple Events approval for the executing automation client; no native-host Notes socket operation exists. |
|
||||||
| Apple Mail | Deferred / out of scope | Legacy Apple Mail automation remains unchanged; separate Thunderbird-local support is not a privacy-host migration. |
|
| Apple Mail | Deferred / out of scope | Legacy Apple Mail automation remains unchanged; separate Thunderbird-local support is not a privacy-host migration. |
|
||||||
| SpeechTranscriber live/file flows | Separate local service | Not part of the native privacy host. |
|
| SpeechTranscriber live/file flows | Direct Reyna CLI execution | Migrated outside the native privacy host; live sessions remain local to the process/service hosting the CLI. |
|
||||||
| Codex/Gemini image generation | Out of scope | Creative/external-service work, not privacy-host scope. |
|
| Codex/Gemini image generation | Direct Reyna CLI execution | Migrated outside the native privacy host; external API/Codex credentials stay on the executing host. |
|
||||||
|
|
||||||
## Cutover rule
|
## Cutover rule
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
C0000000000000000000000D /* RemindersProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000000000000000000011 /* RemindersProvider.swift */; };
|
C0000000000000000000000D /* RemindersProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000000000000000000011 /* RemindersProvider.swift */; };
|
||||||
C0000000000000000000000E /* RemindersAuthorizationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000000000000000000012 /* RemindersAuthorizationProvider.swift */; };
|
C0000000000000000000000E /* RemindersAuthorizationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000000000000000000012 /* RemindersAuthorizationProvider.swift */; };
|
||||||
C00000000000000000000011 /* SystemInfoProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000000000000000000015 /* SystemInfoProvider.swift */; };
|
C00000000000000000000011 /* SystemInfoProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000000000000000000015 /* SystemInfoProvider.swift */; };
|
||||||
|
C00000000000000000000012 /* PythonLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000000000000000000016 /* PythonLauncher.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
@@ -43,6 +44,7 @@
|
|||||||
B00000000000000000000011 /* RemindersProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemindersProvider.swift; sourceTree = "<group>"; };
|
B00000000000000000000011 /* RemindersProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemindersProvider.swift; sourceTree = "<group>"; };
|
||||||
B00000000000000000000012 /* RemindersAuthorizationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemindersAuthorizationProvider.swift; sourceTree = "<group>"; };
|
B00000000000000000000012 /* RemindersAuthorizationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemindersAuthorizationProvider.swift; sourceTree = "<group>"; };
|
||||||
B00000000000000000000015 /* SystemInfoProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemInfoProvider.swift; sourceTree = "<group>"; };
|
B00000000000000000000015 /* SystemInfoProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemInfoProvider.swift; sourceTree = "<group>"; };
|
||||||
|
B00000000000000000000016 /* PythonLauncher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PythonLauncher.swift; sourceTree = "<group>"; };
|
||||||
B0000000000000000000000D /* Reyna CLI.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Reyna CLI.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
B0000000000000000000000D /* Reyna CLI.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Reyna CLI.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
@@ -117,6 +119,7 @@
|
|||||||
B00000000000000000000011 /* RemindersProvider.swift */,
|
B00000000000000000000011 /* RemindersProvider.swift */,
|
||||||
B00000000000000000000012 /* RemindersAuthorizationProvider.swift */,
|
B00000000000000000000012 /* RemindersAuthorizationProvider.swift */,
|
||||||
B00000000000000000000015 /* SystemInfoProvider.swift */,
|
B00000000000000000000015 /* SystemInfoProvider.swift */,
|
||||||
|
B00000000000000000000016 /* PythonLauncher.swift */,
|
||||||
B00000000000000000000008 /* SocketPathValidation.swift */,
|
B00000000000000000000008 /* SocketPathValidation.swift */,
|
||||||
B00000000000000000000009 /* SocketServer.swift */,
|
B00000000000000000000009 /* SocketServer.swift */,
|
||||||
);
|
);
|
||||||
@@ -209,6 +212,7 @@
|
|||||||
C0000000000000000000000D /* RemindersProvider.swift in Sources */,
|
C0000000000000000000000D /* RemindersProvider.swift in Sources */,
|
||||||
C0000000000000000000000E /* RemindersAuthorizationProvider.swift in Sources */,
|
C0000000000000000000000E /* RemindersAuthorizationProvider.swift in Sources */,
|
||||||
C00000000000000000000011 /* SystemInfoProvider.swift in Sources */,
|
C00000000000000000000011 /* SystemInfoProvider.swift in Sources */,
|
||||||
|
C00000000000000000000012 /* PythonLauncher.swift in Sources */,
|
||||||
C00000000000000000000006 /* SocketPathValidation.swift in Sources */,
|
C00000000000000000000006 /* SocketPathValidation.swift in Sources */,
|
||||||
C00000000000000000000007 /* SocketServer.swift in Sources */,
|
C00000000000000000000007 /* SocketServer.swift in Sources */,
|
||||||
C00000000000000000000008 /* CSignalSupport.c in Sources */,
|
C00000000000000000000008 /* CSignalSupport.c in Sources */,
|
||||||
|
|||||||
@@ -5,6 +5,19 @@ import Foundation
|
|||||||
// Headless design: socket-server or stdin JSON-lines mode only.
|
// Headless design: socket-server or stdin JSON-lines mode only.
|
||||||
|
|
||||||
public func runReynaCLIHost(arguments: [String] = CommandLine.arguments) -> Never {
|
public func runReynaCLIHost(arguments: [String] = CommandLine.arguments) -> Never {
|
||||||
|
let hasSocket = arguments.contains("--socket")
|
||||||
|
let hasPython = arguments.contains("--python")
|
||||||
|
|
||||||
|
if hasSocket && hasPython {
|
||||||
|
fputs("error: --python cannot be combined with --socket\n", stderr)
|
||||||
|
Darwin.exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let idx = arguments.firstIndex(of: "--python") {
|
||||||
|
let forwardedArguments = Array(arguments.dropFirst(idx + 1))
|
||||||
|
Darwin.exit(runPythonCLI(forwardedArguments: forwardedArguments))
|
||||||
|
}
|
||||||
|
|
||||||
if let idx = arguments.firstIndex(of: "--socket") {
|
if let idx = arguments.firstIndex(of: "--socket") {
|
||||||
let nextIdx = idx + 1
|
let nextIdx = idx + 1
|
||||||
guard nextIdx < arguments.count else {
|
guard nextIdx < arguments.count else {
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct PythonRuntime: Equatable {
|
||||||
|
public let workingDirectory: URL
|
||||||
|
public let python: URL
|
||||||
|
}
|
||||||
|
|
||||||
|
public func parsePythonRuntimeConfig(_ content: String) -> [String: String] {
|
||||||
|
var values: [String: String] = [:]
|
||||||
|
for rawLine in content.components(separatedBy: .newlines) {
|
||||||
|
let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !line.isEmpty, !line.hasPrefix("#") else { continue }
|
||||||
|
let parts = line.split(separator: "=", maxSplits: 1).map(String.init)
|
||||||
|
guard parts.count == 2 else { continue }
|
||||||
|
let key = parts[0].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
var value = parts[1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if value.count >= 2,
|
||||||
|
((value.hasPrefix("\"") && value.hasSuffix("\"")) || (value.hasPrefix("'") && value.hasSuffix("'"))) {
|
||||||
|
value.removeFirst()
|
||||||
|
value.removeLast()
|
||||||
|
}
|
||||||
|
guard key == "REYNA_CLI_DIR" || key == "REYNA_CLI_PYTHON" else { continue }
|
||||||
|
values[key] = value
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
public func pythonRuntimeConfig(homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> [String: String] {
|
||||||
|
let paths = [
|
||||||
|
homeDirectory.appendingPathComponent(".reyna-cli.env"),
|
||||||
|
homeDirectory.appendingPathComponent(".config/reyna-cli/env"),
|
||||||
|
]
|
||||||
|
var result: [String: String] = [:]
|
||||||
|
for path in paths {
|
||||||
|
guard let content = try? String(contentsOf: path, encoding: .utf8) else { continue }
|
||||||
|
result.merge(parsePythonRuntimeConfig(content)) { _, newest in newest }
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
public func resolvePythonRuntime(
|
||||||
|
homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser,
|
||||||
|
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||||
|
config: [String: String]? = nil,
|
||||||
|
isExecutable: (String) -> Bool = { FileManager.default.isExecutableFile(atPath: $0) }
|
||||||
|
) -> PythonRuntime {
|
||||||
|
let loadedConfig = config ?? pythonRuntimeConfig(homeDirectory: homeDirectory)
|
||||||
|
let defaultWorkDirectory = homeDirectory.appendingPathComponent("Projects/reyna-cli").path
|
||||||
|
let workDirectoryPath = environment["REYNA_CLI_DIR"] ?? loadedConfig["REYNA_CLI_DIR"] ?? defaultWorkDirectory
|
||||||
|
let selectedPython = environment["REYNA_CLI_PYTHON"]
|
||||||
|
?? loadedConfig["REYNA_CLI_PYTHON"]
|
||||||
|
?? URL(fileURLWithPath: workDirectoryPath).appendingPathComponent(".venv/bin/python").path
|
||||||
|
let pythonPath = isExecutable(selectedPython) ? selectedPython : "/usr/bin/python3"
|
||||||
|
return PythonRuntime(
|
||||||
|
workingDirectory: URL(fileURLWithPath: workDirectoryPath),
|
||||||
|
python: URL(fileURLWithPath: pythonPath)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func pythonCommand(runtime: PythonRuntime, forwardedArguments: [String]) -> [String] {
|
||||||
|
[runtime.python.path, "-m", "reyna_cli.cli"] + forwardedArguments
|
||||||
|
}
|
||||||
|
|
||||||
|
public func pythonLaunchEnvironment(from environment: [String: String] = ProcessInfo.processInfo.environment) -> [String: String] {
|
||||||
|
var sanitized = environment
|
||||||
|
for key in ["PYTHONHOME", "PYTHONPATH", "VIRTUAL_ENV"] {
|
||||||
|
sanitized.removeValue(forKey: key)
|
||||||
|
}
|
||||||
|
sanitized["PYTHONNOUSERSITE"] = "1"
|
||||||
|
return sanitized
|
||||||
|
}
|
||||||
|
|
||||||
|
public func runPythonCLI(forwardedArguments: [String]) -> Int32 {
|
||||||
|
let runtime = resolvePythonRuntime()
|
||||||
|
let command = pythonCommand(runtime: runtime, forwardedArguments: forwardedArguments)
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = runtime.python
|
||||||
|
process.arguments = Array(command.dropFirst())
|
||||||
|
process.currentDirectoryURL = runtime.workingDirectory
|
||||||
|
process.environment = pythonLaunchEnvironment()
|
||||||
|
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
process.waitUntilExit()
|
||||||
|
return process.terminationStatus
|
||||||
|
} catch {
|
||||||
|
fputs("error: failed to run Reyna CLI Python: \(error)\n", stderr)
|
||||||
|
return 127
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import ReynaCLIHostCore
|
||||||
|
|
||||||
|
final class PythonLauncherTests: XCTestCase {
|
||||||
|
func testConfiguredRuntimeUsesEnvironmentBeforeConfigAndDefaults() throws {
|
||||||
|
let home = URL(fileURLWithPath: "/tmp/reyna-home")
|
||||||
|
let config = [
|
||||||
|
"REYNA_CLI_DIR": "/config/repo",
|
||||||
|
"REYNA_CLI_PYTHON": "/config/python",
|
||||||
|
]
|
||||||
|
let environment = [
|
||||||
|
"REYNA_CLI_DIR": "/environment/repo",
|
||||||
|
"REYNA_CLI_PYTHON": "/environment/python",
|
||||||
|
]
|
||||||
|
|
||||||
|
let runtime = resolvePythonRuntime(
|
||||||
|
homeDirectory: home,
|
||||||
|
environment: environment,
|
||||||
|
config: config,
|
||||||
|
isExecutable: { path in path == "/environment/python" }
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(runtime.workingDirectory.path, "/environment/repo")
|
||||||
|
XCTAssertEqual(runtime.python.path, "/environment/python")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testConfiguredRuntimeFallsBackToSystemPythonWhenSelectedPythonMissing() throws {
|
||||||
|
let runtime = resolvePythonRuntime(
|
||||||
|
homeDirectory: URL(fileURLWithPath: "/tmp/reyna-home"),
|
||||||
|
environment: [:],
|
||||||
|
config: ["REYNA_CLI_DIR": "/config/repo", "REYNA_CLI_PYTHON": "/missing/python"],
|
||||||
|
isExecutable: { _ in false }
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(runtime.workingDirectory.path, "/config/repo")
|
||||||
|
XCTAssertEqual(runtime.python.path, "/usr/bin/python3")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPythonCommandRunsFixedCliModuleAndForwardsArguments() {
|
||||||
|
let runtime = PythonRuntime(
|
||||||
|
workingDirectory: URL(fileURLWithPath: "/repo"),
|
||||||
|
python: URL(fileURLWithPath: "/repo/.venv/bin/python")
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
pythonCommand(runtime: runtime, forwardedArguments: ["local-services", "speech", "generate", "hello"]),
|
||||||
|
["/repo/.venv/bin/python", "-m", "reyna_cli.cli", "local-services", "speech", "generate", "hello"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPythonEnvironmentRemovesParentInterpreterOverrides() {
|
||||||
|
let environment = pythonLaunchEnvironment(from: [
|
||||||
|
"PATH": "/bin",
|
||||||
|
"PYTHONHOME": "/wrong",
|
||||||
|
"PYTHONPATH": "/wrong/site-packages",
|
||||||
|
"VIRTUAL_ENV": "/wrong/.venv",
|
||||||
|
"KEEP": "yes",
|
||||||
|
])
|
||||||
|
|
||||||
|
XCTAssertNil(environment["PYTHONHOME"])
|
||||||
|
XCTAssertNil(environment["PYTHONPATH"])
|
||||||
|
XCTAssertNil(environment["VIRTUAL_ENV"])
|
||||||
|
XCTAssertEqual(environment["PYTHONNOUSERSITE"], "1")
|
||||||
|
XCTAssertEqual(environment["KEEP"], "yes")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testConfigParserIgnoresCommentsBlankLinesAndQuotes() {
|
||||||
|
let config = parsePythonRuntimeConfig("# comment\nREYNA_CLI_DIR = '/repo path'\n\nREYNA_CLI_PYTHON=\"/python\"\ninvalid\n")
|
||||||
|
|
||||||
|
XCTAssertEqual(config["REYNA_CLI_DIR"], "/repo path")
|
||||||
|
XCTAssertEqual(config["REYNA_CLI_PYTHON"], "/python")
|
||||||
|
XCTAssertNil(config["invalid"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ dependencies = [
|
|||||||
"python-multipart>=0.0.32",
|
"python-multipart>=0.0.32",
|
||||||
"pillow>=12.3.0",
|
"pillow>=12.3.0",
|
||||||
"rmscene>=0.8.0",
|
"rmscene>=0.8.0",
|
||||||
|
"mlx-audio>=0.4.8",
|
||||||
|
"pocket-tts==2.1.0",
|
||||||
]
|
]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional, Tuple
|
||||||
|
from threading import Thread, Event
|
||||||
|
|
||||||
|
SWIFT_SOURCE = """
|
||||||
|
import Foundation
|
||||||
|
import FoundationModels
|
||||||
|
|
||||||
|
struct InMsg: Decodable {
|
||||||
|
var id: String?
|
||||||
|
var mode: String // "line" | "paragraph" | "check" | "quick_reply" | "chat"
|
||||||
|
var text: String?
|
||||||
|
var prev1: String?
|
||||||
|
var prev2: String?
|
||||||
|
var context: String?
|
||||||
|
var prevSource: String?
|
||||||
|
var language: String?
|
||||||
|
var instructions: String?
|
||||||
|
var history: String? // JSON array [{"role":"user","text":".."},...]
|
||||||
|
}
|
||||||
|
struct OutMsg: Encodable {
|
||||||
|
var id: String?
|
||||||
|
var ok: Bool
|
||||||
|
var text: String
|
||||||
|
var error: String?
|
||||||
|
var ms: Int?
|
||||||
|
var mode: String?
|
||||||
|
}
|
||||||
|
func log(_ s: String) { fputs(s+"\\n", stderr) }
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct AppleLLMPolish {
|
||||||
|
static func main() async {
|
||||||
|
let args = CommandLine.arguments
|
||||||
|
if args.contains("--help") || args.contains("-h") {
|
||||||
|
fputs("Usage: apple-llm-polish [--check]\\nPipe JSONL in stdin, JSONL out\\nModes: line, paragraph, quick_reply, chat, check\\n", stderr); exit(0)
|
||||||
|
}
|
||||||
|
if args.contains("--check") { await runCheck(); return }
|
||||||
|
await runPipe()
|
||||||
|
}
|
||||||
|
static func runCheck() async {
|
||||||
|
let m = SystemLanguageModel.default
|
||||||
|
var pingText = "unavailable"
|
||||||
|
var ok = false
|
||||||
|
if m.isAvailable {
|
||||||
|
do {
|
||||||
|
let session = LanguageModelSession(model: m, instructions: "You are concise.")
|
||||||
|
let r = try await session.respond(to: "Say ok")
|
||||||
|
pingText = r.content
|
||||||
|
ok = true
|
||||||
|
} catch { pingText = error.localizedDescription }
|
||||||
|
}
|
||||||
|
let out: [String: Any] = [
|
||||||
|
"available": m.isAvailable,
|
||||||
|
"availability": "\\(m.availability)",
|
||||||
|
"ping": pingText,
|
||||||
|
"ok": ok,
|
||||||
|
"model": "SystemLanguageModel 3B ANE"
|
||||||
|
]
|
||||||
|
if let d = try? JSONSerialization.data(withJSONObject: out), let s = String(data: d, encoding: .utf8) { print(s) }
|
||||||
|
}
|
||||||
|
static func runPipe() async {
|
||||||
|
let m = SystemLanguageModel.default
|
||||||
|
guard m.isAvailable else {
|
||||||
|
let reason = "\\(m.availability)"
|
||||||
|
while let line = readLine() {
|
||||||
|
if line.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { continue }
|
||||||
|
var idv: String? = nil
|
||||||
|
if let data = line.data(using: .utf8), let dict = try? JSONSerialization.jsonObject(with: data) as? [String:Any] { idv = dict["id"] as? String }
|
||||||
|
let out = OutMsg(id: idv, ok: false, text: "", error: "model unavailable: \\(reason)", ms: nil, mode: "error")
|
||||||
|
if let d = try? JSONEncoder().encode(out), let s = String(data: d, encoding: .utf8) { print(s); fflush(stdout) }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let lineSession = LanguageModelSession(model: SystemLanguageModel(useCase: .general, guardrails: .permissiveContentTransformations), instructions: lineSystemPrompt())
|
||||||
|
let paraSession = LanguageModelSession(model: SystemLanguageModel(useCase: .general, guardrails: .permissiveContentTransformations), instructions: paraSystemPrompt())
|
||||||
|
let quickSession = LanguageModelSession(model: SystemLanguageModel(useCase: .general, guardrails: .permissiveContentTransformations), instructions: quickReplySystemPrompt())
|
||||||
|
let chatSession = LanguageModelSession(model: SystemLanguageModel(useCase: .general, guardrails: .permissiveContentTransformations), instructions: "You are Hermes, a concise helpful voice assistant for ESP32 devices. Keep replies under 40 words, warm and concrete, kid-safe.")
|
||||||
|
|
||||||
|
lineSession.prewarm()
|
||||||
|
paraSession.prewarm()
|
||||||
|
quickSession.prewarm()
|
||||||
|
chatSession.prewarm()
|
||||||
|
|
||||||
|
log("[apple-llm] ready, ANE-backed 3B")
|
||||||
|
|
||||||
|
while let line = readLine() {
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmed.isEmpty { continue }
|
||||||
|
guard let data = line.data(using: .utf8), let req = try? JSONDecoder().decode(InMsg.self, from: data) else {
|
||||||
|
let out = OutMsg(id: nil, ok: false, text: "", error: "bad json", ms: nil, mode: "error")
|
||||||
|
if let d = try? JSONEncoder().encode(out), let s = String(data: d, encoding: .utf8) { print(s); fflush(stdout) }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if req.mode == "check" {
|
||||||
|
let out = OutMsg(id: req.id, ok: m.isAvailable, text: "\\(m.availability)", error: nil, ms: 0, mode: "check")
|
||||||
|
if let d = try? JSONEncoder().encode(out), let s = String(data: d, encoding: .utf8) { print(s); fflush(stdout) }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let t0 = Date()
|
||||||
|
do {
|
||||||
|
let (session, prompt, temp): (LanguageModelSession, String, Double)
|
||||||
|
switch req.mode {
|
||||||
|
case "paragraph":
|
||||||
|
session = paraSession
|
||||||
|
prompt = buildParagraphPrompt(context: req.context ?? "", prevSource: req.prevSource ?? "", newText: req.text ?? "")
|
||||||
|
temp = 0.2
|
||||||
|
case "quick_reply":
|
||||||
|
session = quickSession
|
||||||
|
prompt = buildQuickReplyPrompt(draft: req.text ?? "", context: req.context, instructions: req.instructions)
|
||||||
|
temp = 0.4
|
||||||
|
case "chat":
|
||||||
|
session = chatSession
|
||||||
|
if let hist = req.history, !hist.isEmpty {
|
||||||
|
prompt = buildChatPrompt(historyJSON: hist, newText: req.text ?? "", instructions: req.instructions)
|
||||||
|
} else {
|
||||||
|
prompt = req.text ?? ""
|
||||||
|
}
|
||||||
|
temp = 0.5
|
||||||
|
default: // line
|
||||||
|
session = lineSession
|
||||||
|
prompt = buildLinePrompt(text: req.text ?? "", prev1: req.prev1 ?? "", prev2: req.prev2 ?? "")
|
||||||
|
temp = 0.1
|
||||||
|
}
|
||||||
|
var opts = GenerationOptions()
|
||||||
|
opts.temperature = temp
|
||||||
|
let resp = try await session.respond(to: prompt, options: opts)
|
||||||
|
let ms = Int(Date().timeIntervalSince(t0)*1000)
|
||||||
|
let cleaned = resp.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let out = OutMsg(id: req.id, ok: true, text: cleaned.isEmpty ? (req.text ?? "") : cleaned, error: nil, ms: ms, mode: req.mode)
|
||||||
|
if let d = try? JSONEncoder().encode(out), let s = String(data: d, encoding: .utf8) { print(s); fflush(stdout) }
|
||||||
|
} catch {
|
||||||
|
let ms = Int(Date().timeIntervalSince(t0)*1000)
|
||||||
|
let out = OutMsg(id: req.id, ok: false, text: req.text ?? "", error: error.localizedDescription, ms: ms, mode: req.mode)
|
||||||
|
if let d = try? JSONEncoder().encode(out), let s = String(data: d, encoding: .utf8) { print(s); fflush(stdout) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static func lineSystemPrompt() -> String {
|
||||||
|
return "You are a real-time caption polisher. Fix punctuation, casing, STT typos. Remove filler (uh, um). Keep meaning. Output one polished line only."
|
||||||
|
}
|
||||||
|
static func paraSystemPrompt() -> String {
|
||||||
|
return "You are a careful live transcript editor. Goal: most faithful readable English. NEW SOURCE TEXT is primary. PREVIOUS CONTEXT only if helps continuity. English only. No meta commentary. Return revised transcript only."
|
||||||
|
}
|
||||||
|
static func quickReplySystemPrompt() -> String {
|
||||||
|
return \"\"\"
|
||||||
|
You are Hermes instant-reply for ESP32 voice devices (iPhone, Watch, kids). You get LIVE draft transcript from user, possibly partial with typos.
|
||||||
|
|
||||||
|
Goal: produce a super-short, HIGHLY CONTEXTUAL reply preview (max 20 words) that shows you actually understood their specific request, not generic.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|- Reference SPECIFIC keywords/entities from draft: names (Grace Priss/Rain), topics (Mac mini voice, weather, homework), intent.
|
||||||
|
|- Sound human, warm, playful for kids, concise.
|
||||||
|
|- If draft mentions Mac mini voice/boys voice/speech, acknowledge you'll use Mac mini voice.
|
||||||
|
|- If draft mentions a name, use it.
|
||||||
|
|- If draft asks something, hint at answer direction without fully answering (full answer comes next).
|
||||||
|
|- Never say "Thinking on full answer" verbatim — too generic. Instead vary: "Let me check...", "One sec, pulling that...", "Nice name! Love it..."
|
||||||
|
|- Under 20 words. Return ONLY reply text, no quotes.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
Draft: "what's the weather today" -> "Checking weather now — one sec..."
|
||||||
|
Draft: "Perfect. My first name is Grace Priss, and my other name is Grace Reign." -> "Wow, Grace Priss and Grace Reign — royal names! Love them!"
|
||||||
|
Draft: "Why you're not answering with boys voice?" -> "Got it — you want boy voice, switching to Mac mini voice now..."
|
||||||
|
Draft: "Can you use the Mac mini voice to generate answers?" -> "Yes! Using Mac mini voice for better audio, one sec..."
|
||||||
|
Draft: "tell me a joke" -> "Joke coming up..."
|
||||||
|
Draft: "Hey improvement I think now you should show quick response" -> "Nice! Quick response is live, working on full answer too..."
|
||||||
|
\"\"\"
|
||||||
|
}
|
||||||
|
static func buildLinePrompt(text: String, prev1: String, prev2: String) -> String {
|
||||||
|
var p = ""
|
||||||
|
if !prev2.isEmpty { p += "Previous 2: \\(prev2)\\n" }
|
||||||
|
if !prev1.isEmpty { p += "Previous 1: \\(prev1)\\n" }
|
||||||
|
p += "Current: \\(text)\\nPolished:"
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
static func buildParagraphPrompt(context: String, prevSource: String, newText: String) -> String {
|
||||||
|
return "Edit this transcript.\\n[PREVIOUS CONTEXT]\\n\\(context)\\n[PREVIOUS SOURCE]\\n\\(prevSource)\\n[NEW]\\n\\(newText)"
|
||||||
|
}
|
||||||
|
static func buildQuickReplyPrompt(draft: String, context: String?, instructions: String?) -> String {
|
||||||
|
var p = "LIVE DRAFT from user speaking (may have typos, partial): \\"\\(draft)\\"\\n"
|
||||||
|
if let c = context, !c.isEmpty { p += "Previous full transcript: \\(c)\\n" }
|
||||||
|
if let i = instructions, !i.isEmpty { p += "Extra instructions: \\(i)\\n" }
|
||||||
|
p += "\\nTask: produce contextual instant reply (max 20 words) that references SPECIFIC words from draft, not generic. If draft unclear, fall back to 'Heard you — working on full answer...'"
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
static func buildChatPrompt(historyJSON: String, newText: String, instructions: String?) -> String {
|
||||||
|
var prompt = "Conversation history: \\(historyJSON)\\nUser says (draft/final): \\(newText)\\n"
|
||||||
|
if let instructions, !instructions.isEmpty { prompt += "Instructions: \\(instructions)\\n" }
|
||||||
|
return prompt + "Reply concisely for voice device (<40 words):"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
class AppleLLMClient:
|
||||||
|
def __init__(self):
|
||||||
|
self.proc: Optional[subprocess.Popen] = None
|
||||||
|
self.tmp_dir: Optional[Path] = None
|
||||||
|
self.bin_file: Optional[Path] = None
|
||||||
|
self.pending: Dict[str, Tuple[Event, Dict[str, Any]]] = {}
|
||||||
|
self.reader_thread: Optional[Thread] = None
|
||||||
|
self.req_id = 0
|
||||||
|
self.last_activity = time.time()
|
||||||
|
|
||||||
|
def _ensure_built(self):
|
||||||
|
if self.bin_file and self.bin_file.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
self.tmp_dir = Path(tempfile.mkdtemp(prefix="apple-llm-py-"))
|
||||||
|
swift_file = self.tmp_dir / "Main.swift"
|
||||||
|
self.bin_file = self.tmp_dir / "apple-llm-polish"
|
||||||
|
|
||||||
|
swift_file.write_text(SWIFT_SOURCE, encoding="utf-8")
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run([
|
||||||
|
"/usr/bin/swiftc", "-O", "-parse-as-library", str(swift_file),
|
||||||
|
"-o", str(self.bin_file), "-framework", "Foundation", "-framework", "FoundationModels"
|
||||||
|
], check=True, timeout=60, capture_output=True)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
raise RuntimeError(f"swiftc build failed: {e.stderr.decode()}") from e
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
if self.proc and self.proc.poll() is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._ensure_built()
|
||||||
|
|
||||||
|
self.proc = subprocess.Popen(
|
||||||
|
[str(self.bin_file)],
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
bufsize=1
|
||||||
|
)
|
||||||
|
|
||||||
|
self.pending.clear()
|
||||||
|
self.reader_thread = Thread(target=self._read_loop, daemon=True)
|
||||||
|
self.reader_thread.start()
|
||||||
|
|
||||||
|
# Wait for the "ready" log or just a short timeout
|
||||||
|
time.sleep(1.5)
|
||||||
|
if self.proc.poll() is not None:
|
||||||
|
raise RuntimeError(f"AppleLLM process exited early with code {self.proc.returncode}")
|
||||||
|
|
||||||
|
def _read_loop(self):
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
line = self.proc.stdout.readline()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
obj = json.loads(line)
|
||||||
|
req_id = obj.get("id")
|
||||||
|
if req_id and req_id in self.pending:
|
||||||
|
event, result_box = self.pending[req_id]
|
||||||
|
result_box["data"] = obj
|
||||||
|
event.set()
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def call(self, payload: Dict[str, Any], timeout: float = 10.0) -> Dict[str, Any]:
|
||||||
|
self.start()
|
||||||
|
self.last_activity = time.time()
|
||||||
|
|
||||||
|
req_id = str(self.req_id)
|
||||||
|
self.req_id += 1
|
||||||
|
|
||||||
|
full_payload = {"id": req_id, **payload}
|
||||||
|
event = Event()
|
||||||
|
result_box = {"data": None}
|
||||||
|
self.pending[req_id] = (event, result_box)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.proc.stdin.write(json.dumps(full_payload) + "\n")
|
||||||
|
self.proc.stdin.flush()
|
||||||
|
|
||||||
|
if event.wait(timeout):
|
||||||
|
res = result_box["data"]
|
||||||
|
return res if res else {"ok": False, "error": "empty response"}
|
||||||
|
else:
|
||||||
|
return {"ok": False, "error": f"timeout {timeout}s", "id": req_id, **payload}
|
||||||
|
finally:
|
||||||
|
self.pending.pop(req_id, None)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
if self.proc:
|
||||||
|
try:
|
||||||
|
self.proc.stdin.close()
|
||||||
|
self.proc.terminate()
|
||||||
|
self.proc.wait(timeout=2)
|
||||||
|
except Exception:
|
||||||
|
self.proc.kill()
|
||||||
|
if self.tmp_dir and self.tmp_dir.exists():
|
||||||
|
shutil.rmtree(self.tmp_dir)
|
||||||
|
self.proc = None
|
||||||
|
self.bin_file = None
|
||||||
|
|
||||||
|
def check(self) -> Dict[str, Any]:
|
||||||
|
# For check, we can use the --check flag as a separate run, or just call the pipe.
|
||||||
|
# The Swift code supports --check for a one-off ping.
|
||||||
|
self._ensure_built()
|
||||||
|
try:
|
||||||
|
res = subprocess.run(
|
||||||
|
[str(self.bin_file), "--check"],
|
||||||
|
capture_output=True, text=True, timeout=10, check=True
|
||||||
|
)
|
||||||
|
return json.loads(res.stdout)
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
# Singleton for warm session
|
||||||
|
_global_session: Optional[AppleLLMClient] = None
|
||||||
|
|
||||||
|
def get_apple_llm_session() -> AppleLLMClient:
|
||||||
|
global _global_session
|
||||||
|
if _global_session is None:
|
||||||
|
_global_session = AppleLLMClient()
|
||||||
|
return _global_session
|
||||||
|
|
||||||
|
def apple_llm_close():
|
||||||
|
global _global_session
|
||||||
|
if _global_session:
|
||||||
|
_global_session.close()
|
||||||
|
_global_session = None
|
||||||
|
return {"ok": True, "closed": True}
|
||||||
|
|
||||||
|
def apple_llm_status():
|
||||||
|
s = _global_session
|
||||||
|
return {
|
||||||
|
"active": s is not None,
|
||||||
|
"ready": s is not None and s.proc is not None and s.proc.poll() is None,
|
||||||
|
"last_activity": datetime.fromtimestamp(s.last_activity, timezone.utc).isoformat() if s else None,
|
||||||
|
"pid": s.proc.pid if s and s.proc else None
|
||||||
|
}
|
||||||
+536
-31
@@ -6,36 +6,47 @@ import subprocess
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
import yaml
|
import yaml
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
from reyna_cli.config import Device, cache_path_for, get_device, load_registry
|
from reyna_cli.config import Device, cache_path_for, get_device, is_tactility_device, load_registry, resolve_device_host
|
||||||
|
from reyna_cli.apple_llm_client import (
|
||||||
|
apple_llm_close,
|
||||||
|
apple_llm_status,
|
||||||
|
get_apple_llm_session,
|
||||||
|
)
|
||||||
from reyna_cli.deco_direct import DecoDirectClient
|
from reyna_cli.deco_direct import DecoDirectClient
|
||||||
from reyna_cli.desktop_service import SERVICE_NAME, service_action, service_status, unit_content, unit_path
|
from reyna_cli.desktop_service import SERVICE_NAME, service_action, service_status, unit_content, unit_path
|
||||||
from reyna_cli.email_local import ThunderbirdEmailClient, email_tool_specs
|
from reyna_cli.email_local import ThunderbirdEmailClient, email_tool_specs
|
||||||
from reyna_cli.immich import ImmichClient
|
from reyna_cli.immich import ImmichClient
|
||||||
from reyna_cli.mcp import MCPClient
|
from reyna_cli.mcp import MCPClient
|
||||||
from reyna_cli.mongo_direct import MongoDirectClient
|
from reyna_cli.mongo_direct import MongoDirectClient
|
||||||
|
from reyna_cli.notes_direct import NotesAutomationError, create_note, list_notes, read_note
|
||||||
from reyna_cli.privacy_client import default_socket_path as privacy_default_socket_path
|
from reyna_cli.privacy_client import default_socket_path as privacy_default_socket_path
|
||||||
from reyna_cli.privacy_host import native_calendar_list, privacy_host_status_payload
|
from reyna_cli.privacy_host import native_calendar_list, privacy_host_status_payload
|
||||||
from reyna_cli.remarkable import LISTENER_LABEL, listen_forever, listener_service_action, listener_service_status, sync_once
|
from reyna_cli.remarkable import LISTENER_LABEL, listen_forever, listener_service_action, listener_service_status, sync_once
|
||||||
from reyna_cli.tts import TTSError, synthesize_wav
|
from reyna_cli.tts import TTSError, synthesize_wav
|
||||||
|
from reyna_cli.tactility import TactilityClient
|
||||||
from reyna_cli.utils import infer_capabilities, resolve_tool_name
|
from reyna_cli.utils import infer_capabilities, resolve_tool_name
|
||||||
|
from reyna_cli.voice_direct import UnifiedVoiceClient
|
||||||
from reyna_cli.zoom_direct import ZoomClient
|
from reyna_cli.zoom_direct import ZoomClient
|
||||||
|
|
||||||
# Backward-compatible module alias used by existing CLI tests/mocks.
|
# Backward-compatible module alias used by existing CLI tests/mocks.
|
||||||
MongoClient = MongoDirectClient
|
MongoClient = MongoDirectClient
|
||||||
|
|
||||||
app = typer.Typer(help="Reyna family CLI for on-demand local-device and service control.")
|
app = typer.Typer(help="Reyna family CLI for on-demand local-device and service control.")
|
||||||
devices_app = typer.Typer(help="Manage local devices (screen, laptop, iphone, arm).")
|
devices_app = typer.Typer(help="Manage local devices (Tactility boards, screens, phone).")
|
||||||
screen_app = typer.Typer(help="ESP32 screen wrapper commands.")
|
screen_app = typer.Typer(help="ESP32 screen wrapper commands.")
|
||||||
laptop_app = typer.Typer(help="Personal laptop MCP screen wrapper commands.")
|
laptop_app = typer.Typer(help="Personal laptop MCP screen wrapper commands.")
|
||||||
computer_app = typer.Typer(help="This computer's local desktop companion client.")
|
computer_app = typer.Typer(help="This computer's local desktop companion client.")
|
||||||
iphone_app = typer.Typer(help="iPhone MCP app wrapper commands.")
|
iphone_app = typer.Typer(help="iPhone MCP app wrapper commands.")
|
||||||
arm_app = typer.Typer(help="Robot arm wrapper commands.")
|
tactility_app = typer.Typer(help="Manage Tactility ESP32 boards over the LAN web API.")
|
||||||
|
tactility_fs_app = typer.Typer(help="Manipulate files on a Tactility board.")
|
||||||
|
tactility_screen_app = typer.Typer(help="Draw text on a Tactility board screen.")
|
||||||
immich_app = typer.Typer(help="Immich direct REST API commands.")
|
immich_app = typer.Typer(help="Immich direct REST API commands.")
|
||||||
mongo_app = typer.Typer(help="MongoDB direct driver commands.")
|
mongo_app = typer.Typer(help="MongoDB direct driver commands.")
|
||||||
zoom_app = typer.Typer(help="Zoom direct REST API commands.")
|
zoom_app = typer.Typer(help="Zoom direct REST API commands.")
|
||||||
@@ -47,6 +58,7 @@ macmini_calendar_app = typer.Typer(help="Mac mini Calendar tools.")
|
|||||||
macmini_contacts_app = typer.Typer(help="Mac mini Contacts tools.")
|
macmini_contacts_app = typer.Typer(help="Mac mini Contacts tools.")
|
||||||
macmini_reminders_app = typer.Typer(help="Mac mini Reminders tools.")
|
macmini_reminders_app = typer.Typer(help="Mac mini Reminders tools.")
|
||||||
macmini_deco_app = typer.Typer(help="TP-Link Deco direct router commands (backward-compatible alias).")
|
macmini_deco_app = typer.Typer(help="TP-Link Deco direct router commands (backward-compatible alias).")
|
||||||
|
notes_app = typer.Typer(help="Direct Apple Notes tools (mutable Python; no native rebuild).")
|
||||||
privacy_host_app = typer.Typer(help="Native privacy host commands.")
|
privacy_host_app = typer.Typer(help="Native privacy host commands.")
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
@@ -55,13 +67,14 @@ devices_app.add_typer(screen_app, name="screen")
|
|||||||
devices_app.add_typer(laptop_app, name="laptop")
|
devices_app.add_typer(laptop_app, name="laptop")
|
||||||
devices_app.add_typer(computer_app, name="computer")
|
devices_app.add_typer(computer_app, name="computer")
|
||||||
devices_app.add_typer(iphone_app, name="iphone")
|
devices_app.add_typer(iphone_app, name="iphone")
|
||||||
devices_app.add_typer(arm_app, name="arm")
|
|
||||||
app.add_typer(devices_app, name="devices")
|
app.add_typer(devices_app, name="devices")
|
||||||
app.add_typer(screen_app, name="screen", hidden=True)
|
app.add_typer(screen_app, name="screen", hidden=True)
|
||||||
app.add_typer(laptop_app, name="laptop", hidden=True)
|
app.add_typer(laptop_app, name="laptop", hidden=True)
|
||||||
app.add_typer(computer_app, name="computer")
|
app.add_typer(computer_app, name="computer")
|
||||||
app.add_typer(iphone_app, name="iphone", hidden=True)
|
app.add_typer(iphone_app, name="iphone", hidden=True)
|
||||||
app.add_typer(arm_app, name="arm", hidden=True)
|
app.add_typer(tactility_app, name="tactility")
|
||||||
|
tactility_app.add_typer(tactility_fs_app, name="fs")
|
||||||
|
tactility_app.add_typer(tactility_screen_app, name="screen")
|
||||||
app.add_typer(immich_app, name="immich")
|
app.add_typer(immich_app, name="immich")
|
||||||
app.add_typer(mongo_app, name="mongo")
|
app.add_typer(mongo_app, name="mongo")
|
||||||
app.add_typer(zoom_app, name="zoom")
|
app.add_typer(zoom_app, name="zoom")
|
||||||
@@ -71,11 +84,64 @@ macmini_app.add_typer(macmini_calendar_app, name="calendar")
|
|||||||
macmini_app.add_typer(macmini_contacts_app, name="contacts")
|
macmini_app.add_typer(macmini_contacts_app, name="contacts")
|
||||||
macmini_app.add_typer(macmini_reminders_app, name="reminders")
|
macmini_app.add_typer(macmini_reminders_app, name="reminders")
|
||||||
macmini_app.add_typer(macmini_deco_app, name="deco")
|
macmini_app.add_typer(macmini_deco_app, name="deco")
|
||||||
|
macmini_app.add_typer(notes_app, name="notes")
|
||||||
app.add_typer(macmini_app, name="macmini")
|
app.add_typer(macmini_app, name="macmini")
|
||||||
|
app.add_typer(notes_app, name="notes")
|
||||||
app.add_typer(remarkable_app, name="remarkable")
|
app.add_typer(remarkable_app, name="remarkable")
|
||||||
app.add_typer(privacy_host_app, name="privacy-host")
|
app.add_typer(privacy_host_app, name="privacy-host")
|
||||||
|
|
||||||
|
|
||||||
|
@app.command("signed", context_settings={"allow_extra_args": True, "ignore_unknown_options": True})
|
||||||
|
def signed_python(ctx: typer.Context):
|
||||||
|
"""Run mutable Reyna CLI Python logic through the signed native app."""
|
||||||
|
from reyna_cli.signed_launcher import SignedLauncherError, run_signed_python
|
||||||
|
|
||||||
|
try:
|
||||||
|
exit_code = run_signed_python(ctx.args)
|
||||||
|
except SignedLauncherError as exc:
|
||||||
|
fail(str(exc))
|
||||||
|
if exit_code:
|
||||||
|
raise typer.Exit(exit_code)
|
||||||
|
|
||||||
|
|
||||||
|
@notes_app.command("list")
|
||||||
|
def notes_list(
|
||||||
|
query: Optional[str] = None,
|
||||||
|
folder: Optional[str] = None,
|
||||||
|
include_preview: bool = typer.Option(False, "--include-preview"),
|
||||||
|
limit: int = 20,
|
||||||
|
json_output: bool = typer.Option(False, "--json"),
|
||||||
|
):
|
||||||
|
"""List Apple Notes through direct fixed-script macOS automation."""
|
||||||
|
try:
|
||||||
|
emit({"ok": True, "source": "direct_apple_notes", "notes": list_notes(query=query, folder=folder, include_preview=include_preview, limit=limit)}, json_output)
|
||||||
|
except (NotesAutomationError, ValueError) as exc:
|
||||||
|
fail(str(exc), json_output)
|
||||||
|
|
||||||
|
|
||||||
|
@notes_app.command("read")
|
||||||
|
def notes_read(note_id: str, json_output: bool = typer.Option(False, "--json")):
|
||||||
|
"""Read one Apple Note by the identifier returned from ``notes list``."""
|
||||||
|
try:
|
||||||
|
emit({"ok": True, "source": "direct_apple_notes", "note": read_note(note_id)}, json_output)
|
||||||
|
except (NotesAutomationError, ValueError) as exc:
|
||||||
|
fail(str(exc), json_output)
|
||||||
|
|
||||||
|
|
||||||
|
@notes_app.command("create")
|
||||||
|
def notes_create(
|
||||||
|
title: str,
|
||||||
|
body: str = "",
|
||||||
|
folder: Optional[str] = None,
|
||||||
|
json_output: bool = typer.Option(False, "--json"),
|
||||||
|
):
|
||||||
|
"""Create an Apple Note. Note text is passed as JSON, never script source."""
|
||||||
|
try:
|
||||||
|
emit({"ok": True, "source": "direct_apple_notes", "note": create_note(title, body, folder=folder)}, json_output)
|
||||||
|
except (NotesAutomationError, ValueError) as exc:
|
||||||
|
fail(str(exc), json_output)
|
||||||
|
|
||||||
|
|
||||||
def scrub_sensitive(value: Any) -> Any:
|
def scrub_sensitive(value: Any) -> Any:
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
cleaned: Dict[str, Any] = {}
|
cleaned: Dict[str, Any] = {}
|
||||||
@@ -778,43 +844,237 @@ def iphone_battery(json_output: bool = typer.Option(False, "--json")):
|
|||||||
wrapper_capability("iphone_mcp", "battery", {}, json_output)
|
wrapper_capability("iphone_mcp", "battery", {}, json_output)
|
||||||
|
|
||||||
|
|
||||||
@arm_app.command("tools")
|
def tactility_devices() -> List[Device]:
|
||||||
def arm_tools(json_output: bool = typer.Option(False, "--json"), live_only: bool = False, cache_only: bool = False, refresh: bool = False):
|
return [device for device in load_registry().devices if is_tactility_device(device)]
|
||||||
wrapper_tools("robot_arm", json_output, live_only, cache_only, refresh)
|
|
||||||
|
|
||||||
|
|
||||||
@arm_app.command("describe")
|
def tactility_device(name: str, json_output: bool = False) -> Device:
|
||||||
def arm_describe(json_output: bool = typer.Option(False, "--json"), for_hermes: bool = typer.Option(False, "--for-hermes")):
|
device = get_device(name)
|
||||||
wrapper_describe("robot_arm", json_output, for_hermes)
|
if not device or not is_tactility_device(device):
|
||||||
|
fail(f"Tactility device '{name}' not found", json_output)
|
||||||
|
assert device is not None
|
||||||
|
return device
|
||||||
|
|
||||||
|
|
||||||
@arm_app.command("call")
|
def tactility_client(device: Device, timeout: float = 30.0) -> TactilityClient:
|
||||||
def arm_call(tool: str, args: str = typer.Option("{}", "--args"), json_output: bool = typer.Option(False, "--json")):
|
host = resolve_device_host(device)
|
||||||
device = get_required_device("robot_arm", json_output)
|
if not host:
|
||||||
|
raise RuntimeError(f"No host or reserved IP configured for {device.id}")
|
||||||
|
scheme = urlsplit(device.url).scheme or "http"
|
||||||
|
return TactilityClient(f"{scheme}://{host}", timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def tactility_result(device: Device, operation: str, fn: Any, json_output: bool) -> None:
|
||||||
|
try:
|
||||||
|
emit({"ok": True, "device": device.id, "host": resolve_device_host(device), "operation": operation, "result": fn()}, json_output)
|
||||||
|
except Exception as exc:
|
||||||
|
fail(str(exc), json_output, device=device.id, operation=operation)
|
||||||
|
|
||||||
|
|
||||||
|
def tactility_report(device: Device) -> Dict[str, Any]:
|
||||||
|
client = tactility_client(device)
|
||||||
|
report: Dict[str, Any] = {
|
||||||
|
"device": device.id,
|
||||||
|
"display_name": device.display_name,
|
||||||
|
"host": device.host,
|
||||||
|
"resolved_ip": resolve_device_host(device),
|
||||||
|
"apps": client.apps(),
|
||||||
|
"sysinfo": client.sysinfo(),
|
||||||
|
"bible": {},
|
||||||
|
"podcast": {"state_files": {}, "downloaded_episodes": []},
|
||||||
|
}
|
||||||
|
for key, path in {
|
||||||
|
"favorites": "/sdcard/user/app/one.tactility.bibleverse/favorites.txt",
|
||||||
|
"progress": "/sdcard/user/app/one.tactility.bibleverse/progress.txt",
|
||||||
|
}.items():
|
||||||
|
try:
|
||||||
|
raw = client.fs_read(path)
|
||||||
|
report["bible"][key] = raw
|
||||||
|
if key == "favorites":
|
||||||
|
report["bible"]["favorite_indices"] = [int(line) for line in raw.splitlines() if line.strip().isdigit()]
|
||||||
|
except Exception as exc:
|
||||||
|
report["bible"][f"{key}_error"] = str(exc)
|
||||||
|
for path in (
|
||||||
|
"/sdcard/apps/one.tactility.discoverymountain/userdata/state.json",
|
||||||
|
"/sdcard/apps/one.tactility.discoverymountain/userdata/progress.json",
|
||||||
|
"/sdcard/mp3_last_pos.json",
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
raw = client.fs_read(path)
|
||||||
|
try:
|
||||||
|
report["podcast"]["state_files"][path] = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
report["podcast"]["state_files"][path] = raw
|
||||||
|
except Exception as exc:
|
||||||
|
report["podcast"]["state_files"][path] = {"error": str(exc)}
|
||||||
|
try:
|
||||||
|
entries = client.fs_list("/sdcard/dm").get("entries", [])
|
||||||
|
episodes = [
|
||||||
|
{"name": item.get("name"), "size": item.get("size")}
|
||||||
|
for item in entries
|
||||||
|
if item.get("type") == "file" and str(item.get("name", "")).lower().endswith(".mp3")
|
||||||
|
]
|
||||||
|
report["podcast"]["downloaded_episodes"] = sorted(episodes, key=lambda item: item["name"] or "")
|
||||||
|
report["podcast"]["latest_downloaded_episode"] = max(
|
||||||
|
report["podcast"]["downloaded_episodes"],
|
||||||
|
key=lambda item: int(str(item["name"]).split("_", 1)[0]) if str(item["name"]).split("_", 1)[0].isdigit() else -1,
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
report["podcast"]["episodes_error"] = str(exc)
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
@tactility_app.command("report")
|
||||||
|
def tactility_report_command(name: str, json_output: bool = typer.Option(False, "--json")):
|
||||||
|
"""Read installed apps, SD capacity, Bible state, and podcast usage files."""
|
||||||
|
device = tactility_device(name, json_output)
|
||||||
|
tactility_result(device, "report", lambda: tactility_report(device), json_output)
|
||||||
|
|
||||||
|
|
||||||
|
@tactility_app.command("discover")
|
||||||
|
def tactility_discover(json_output: bool = typer.Option(False, "--json")):
|
||||||
|
"""Probe every registered Tactility board; offline boards remain in the report."""
|
||||||
|
results = []
|
||||||
|
for device in tactility_devices():
|
||||||
|
host = resolve_device_host(device)
|
||||||
|
item: Dict[str, Any] = {"device": device.id, "display_name": device.display_name, "mdns_host": device.host, "resolved_ip": host, "online": False}
|
||||||
|
try:
|
||||||
|
item["sysinfo"] = tactility_client(device, timeout=3.0).sysinfo()
|
||||||
|
item["online"] = True
|
||||||
|
except Exception as exc:
|
||||||
|
item["error"] = str(exc)
|
||||||
|
results.append(item)
|
||||||
|
emit({"ok": True, "devices": results}, json_output)
|
||||||
|
|
||||||
|
|
||||||
|
@tactility_app.command("sysinfo")
|
||||||
|
def tactility_sysinfo(name: str, json_output: bool = typer.Option(False, "--json")):
|
||||||
|
device = tactility_device(name, json_output)
|
||||||
|
tactility_result(device, "sysinfo", lambda: tactility_client(device).sysinfo(), json_output)
|
||||||
|
|
||||||
|
|
||||||
|
@tactility_app.command("apps")
|
||||||
|
def tactility_apps(name: str, json_output: bool = typer.Option(False, "--json")):
|
||||||
|
device = tactility_device(name, json_output)
|
||||||
|
tactility_result(device, "apps", lambda: tactility_client(device).apps(), json_output)
|
||||||
|
|
||||||
|
|
||||||
|
@tactility_app.command("tools")
|
||||||
|
def tactility_tools(name: str, json_output: bool = typer.Option(False, "--json"), live_only: bool = False, cache_only: bool = False, refresh: bool = False):
|
||||||
|
"""List the board's live/cached MCP tools, including image and audio tools."""
|
||||||
|
device = tactility_device(name, json_output)
|
||||||
|
result = get_tools(device, live_only=live_only, cache_only=cache_only, refresh=refresh)
|
||||||
|
emit(result, json_output)
|
||||||
|
if not result.get("ok"):
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@tactility_app.command("describe")
|
||||||
|
def tactility_describe(name: str, json_output: bool = typer.Option(False, "--json"), for_hermes: bool = typer.Option(False, "--for-hermes")):
|
||||||
|
"""Describe MCP capabilities for a Tactility board."""
|
||||||
|
devices_describe_cmd(name, json_output=json_output, for_hermes=for_hermes)
|
||||||
|
|
||||||
|
|
||||||
|
@tactility_app.command("call")
|
||||||
|
def tactility_call(name: str, tool: str, args: str = typer.Option("{}", "--args"), json_output: bool = typer.Option(False, "--json")):
|
||||||
|
"""Call any live Tactility MCP tool (image, audio, sensors, files, and more)."""
|
||||||
|
device = tactility_device(name, json_output)
|
||||||
result = call_device_tool(device, tool, parse_args_json(args, json_output))
|
result = call_device_tool(device, tool, parse_args_json(args, json_output))
|
||||||
emit(result, json_output)
|
emit(result, json_output)
|
||||||
if not result.get("ok"):
|
if not result.get("ok"):
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
@arm_app.command("state")
|
@tactility_app.command("install")
|
||||||
def arm_state(json_output: bool = typer.Option(False, "--json")):
|
def tactility_install(name: str, app_file: Path, json_output: bool = typer.Option(False, "--json")):
|
||||||
wrapper_capability("robot_arm", "state", {}, json_output)
|
device = tactility_device(name, json_output)
|
||||||
|
if not app_file.is_file():
|
||||||
|
fail(f"App file not found: {app_file}", json_output)
|
||||||
|
tactility_result(device, "install", lambda: tactility_client(device).install_app(app_file), json_output)
|
||||||
|
|
||||||
|
|
||||||
@arm_app.command("battery")
|
@tactility_app.command("run")
|
||||||
def arm_battery(json_output: bool = typer.Option(False, "--json")):
|
def tactility_run(name: str, app_id: str, json_output: bool = typer.Option(False, "--json")):
|
||||||
wrapper_capability("robot_arm", "battery", {}, json_output)
|
device = tactility_device(name, json_output)
|
||||||
|
tactility_result(device, "run", lambda: tactility_client(device).run_app(app_id), json_output)
|
||||||
|
|
||||||
|
|
||||||
@arm_app.command("home")
|
@tactility_screen_app.command("clear")
|
||||||
def arm_home(json_output: bool = typer.Option(False, "--json")):
|
def tactility_screen_clear(
|
||||||
wrapper_capability("robot_arm", "home", {}, json_output)
|
name: str,
|
||||||
|
width: int = typer.Option(320, "--width", min=1),
|
||||||
|
height: int = typer.Option(240, "--height", min=1),
|
||||||
|
json_output: bool = typer.Option(False, "--json"),
|
||||||
|
):
|
||||||
|
"""Clear the MCP drawing area using the board's native display tool."""
|
||||||
|
device = tactility_device(name, json_output)
|
||||||
|
tactility_result(device, "screen.clear", lambda: call_device_tool(device, "clear_screen", {"color": 0}), json_output)
|
||||||
|
|
||||||
|
|
||||||
@arm_app.command("wave")
|
@tactility_screen_app.command("text")
|
||||||
def arm_wave(json_output: bool = typer.Option(False, "--json")):
|
def tactility_screen_text(
|
||||||
wrapper_capability("robot_arm", "wave", {}, json_output)
|
name: str,
|
||||||
|
message: str,
|
||||||
|
width: int = typer.Option(320, "--width", min=1),
|
||||||
|
height: int = typer.Option(240, "--height", min=1),
|
||||||
|
clear_first: bool = typer.Option(True, "--clear-first/--no-clear-first"),
|
||||||
|
x: int = typer.Option(10, "--x"),
|
||||||
|
y: int = typer.Option(20, "--y"),
|
||||||
|
size: int = typer.Option(2, "--size", min=1, max=2),
|
||||||
|
json_output: bool = typer.Option(False, "--json"),
|
||||||
|
):
|
||||||
|
"""Render text through MCP, optionally clearing first."""
|
||||||
|
device = tactility_device(name, json_output)
|
||||||
|
|
||||||
|
def draw():
|
||||||
|
cleared = None
|
||||||
|
if clear_first:
|
||||||
|
cleared = call_device_tool(device, "clear_screen", {"color": 0})
|
||||||
|
if not cleared.get("ok"):
|
||||||
|
return {"ok": False, "clear": cleared, "error": "clear_failed"}
|
||||||
|
drawn = call_device_tool(device, "draw_text", {"text": message, "x": x, "y": y, "size": size})
|
||||||
|
return {"ok": bool(drawn.get("ok")), "clear": cleared, "draw": drawn}
|
||||||
|
|
||||||
|
tactility_result(device, "screen.text", draw, json_output)
|
||||||
|
|
||||||
|
|
||||||
|
@tactility_fs_app.command("list")
|
||||||
|
def tactility_fs_list(name: str, path: str = typer.Option("/", "--path"), json_output: bool = typer.Option(False, "--json")):
|
||||||
|
device = tactility_device(name, json_output)
|
||||||
|
tactility_result(device, "fs.list", lambda: tactility_client(device).fs_list(path), json_output)
|
||||||
|
|
||||||
|
|
||||||
|
@tactility_fs_app.command("mkdir")
|
||||||
|
def tactility_fs_mkdir(name: str, path: str, json_output: bool = typer.Option(False, "--json")):
|
||||||
|
device = tactility_device(name, json_output)
|
||||||
|
tactility_result(device, "fs.mkdir", lambda: tactility_client(device).fs_mkdir(path), json_output)
|
||||||
|
|
||||||
|
|
||||||
|
@tactility_fs_app.command("upload")
|
||||||
|
def tactility_fs_upload(name: str, local_file: Path, remote_path: str, json_output: bool = typer.Option(False, "--json")):
|
||||||
|
device = tactility_device(name, json_output)
|
||||||
|
if not local_file.is_file():
|
||||||
|
fail(f"Local file not found: {local_file}", json_output)
|
||||||
|
tactility_result(device, "fs.upload", lambda: tactility_client(device).fs_upload(local_file, remote_path), json_output)
|
||||||
|
|
||||||
|
|
||||||
|
@tactility_fs_app.command("download")
|
||||||
|
def tactility_fs_download(name: str, remote_path: str, local_file: Path, json_output: bool = typer.Option(False, "--json")):
|
||||||
|
device = tactility_device(name, json_output)
|
||||||
|
tactility_result(device, "fs.download", lambda: tactility_client(device).fs_download(remote_path, local_file), json_output)
|
||||||
|
|
||||||
|
|
||||||
|
@tactility_fs_app.command("delete")
|
||||||
|
def tactility_fs_delete(name: str, path: str, json_output: bool = typer.Option(False, "--json")):
|
||||||
|
device = tactility_device(name, json_output)
|
||||||
|
tactility_result(device, "fs.delete", lambda: tactility_client(device).fs_delete(path), json_output)
|
||||||
|
|
||||||
|
|
||||||
|
@tactility_fs_app.command("rename")
|
||||||
|
def tactility_fs_rename(name: str, path: str, new_name: str, json_output: bool = typer.Option(False, "--json")):
|
||||||
|
device = tactility_device(name, json_output)
|
||||||
|
tactility_result(device, "fs.rename", lambda: tactility_client(device).fs_rename(path, new_name), json_output)
|
||||||
|
|
||||||
|
|
||||||
def immich_tool_specs() -> List[Dict[str, Any]]:
|
def immich_tool_specs() -> List[Dict[str, Any]]:
|
||||||
@@ -1416,17 +1676,21 @@ def macmini_speech_api_status(json_output: bool = typer.Option(False, "--json"))
|
|||||||
|
|
||||||
# ─── Local services direct wrappers (B) ──────────────────────────────────────
|
# ─── Local services direct wrappers (B) ──────────────────────────────────────
|
||||||
|
|
||||||
local_services_app = typer.Typer(help="Local TTS/STT/Voice services direct (Kokoro, Voicebox, Apple LLM, Speech, Image) — no MCP.")
|
local_services_app = typer.Typer(help="Local TTS/STT/Voice services direct (Kokoro, Qwen3-TTS, Voicebox, Apple LLM, Speech, Image) — no MCP.")
|
||||||
speech_direct_app = typer.Typer(help="macOS say + SpeechTranscriber direct.")
|
speech_direct_app = typer.Typer(help="macOS say + SpeechTranscriber direct.")
|
||||||
kokoro_app = typer.Typer(help="Kokoro ksay TTS daemon direct.")
|
kokoro_app = typer.Typer(help="Kokoro ksay TTS daemon direct.")
|
||||||
|
qwen3_tts_app = typer.Typer(help="Local MLX Qwen3-TTS JV voice-cloning direct.")
|
||||||
voicebox_direct_app = typer.Typer(help="Voicebox Qwen3-TTS direct.")
|
voicebox_direct_app = typer.Typer(help="Voicebox Qwen3-TTS direct.")
|
||||||
|
unified_voice_app = typer.Typer(help="Unified local voice generation: Kokoro, Pocket JV, or Qwen JV.")
|
||||||
apple_llm_app = typer.Typer(help="Apple ANE 3B LLM direct.")
|
apple_llm_app = typer.Typer(help="Apple ANE 3B LLM direct.")
|
||||||
image_direct_app = typer.Typer(help="Image generation config (Codex/Gemini) direct — config only.")
|
image_direct_app = typer.Typer(help="Image generation execution (Codex/Gemini) direct.")
|
||||||
system_direct_app = typer.Typer(help="Local system info direct (offline safe).")
|
system_direct_app = typer.Typer(help="Local system info direct (offline safe).")
|
||||||
|
|
||||||
local_services_app.add_typer(speech_direct_app, name="speech")
|
local_services_app.add_typer(speech_direct_app, name="speech")
|
||||||
local_services_app.add_typer(kokoro_app, name="kokoro")
|
local_services_app.add_typer(kokoro_app, name="kokoro")
|
||||||
|
local_services_app.add_typer(qwen3_tts_app, name="qwen3-tts")
|
||||||
local_services_app.add_typer(voicebox_direct_app, name="voicebox")
|
local_services_app.add_typer(voicebox_direct_app, name="voicebox")
|
||||||
|
local_services_app.add_typer(unified_voice_app, name="voice")
|
||||||
local_services_app.add_typer(apple_llm_app, name="apple-llm")
|
local_services_app.add_typer(apple_llm_app, name="apple-llm")
|
||||||
local_services_app.add_typer(image_direct_app, name="image")
|
local_services_app.add_typer(image_direct_app, name="image")
|
||||||
local_services_app.add_typer(system_direct_app, name="system")
|
local_services_app.add_typer(system_direct_app, name="system")
|
||||||
@@ -1453,6 +1717,53 @@ def speech_direct_voices(json_output: bool = typer.Option(False, "--json")):
|
|||||||
fail(str(exc), json_output)
|
fail(str(exc), json_output)
|
||||||
|
|
||||||
|
|
||||||
|
@speech_direct_app.command("generate")
|
||||||
|
def speech_direct_generate(
|
||||||
|
text: str,
|
||||||
|
out: Path = typer.Option(Path("~/Projects/MacMiniMCP/generated-audio/reyna-tts.wav"), "--out"),
|
||||||
|
voice: Optional[str] = typer.Option(None, "--voice"),
|
||||||
|
sample_rate: int = typer.Option(16000, "--sample-rate"),
|
||||||
|
json_output: bool = typer.Option(False, "--json"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
from reyna_cli.media_execution import generate_local_tts
|
||||||
|
|
||||||
|
emit(generate_local_tts(text, out, voice=voice, sample_rate=sample_rate), json_output)
|
||||||
|
except Exception as exc:
|
||||||
|
fail(str(exc), json_output)
|
||||||
|
|
||||||
|
|
||||||
|
@speech_direct_app.command("transcribe-file")
|
||||||
|
def speech_direct_transcribe_file(
|
||||||
|
audio: Path,
|
||||||
|
locale: str = typer.Option("en-US", "--locale"),
|
||||||
|
timeout: int = typer.Option(300, "--timeout"),
|
||||||
|
json_output: bool = typer.Option(False, "--json"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
from reyna_cli.speech_execution import transcribe_file
|
||||||
|
|
||||||
|
emit(transcribe_file(audio, locale=locale, timeout=timeout), json_output)
|
||||||
|
except Exception as exc:
|
||||||
|
fail(str(exc), json_output)
|
||||||
|
|
||||||
|
|
||||||
|
@speech_direct_app.command("live-transcribe")
|
||||||
|
def speech_direct_live_transcribe(
|
||||||
|
audio: Path,
|
||||||
|
locale: str = typer.Option("en-US", "--locale"),
|
||||||
|
timeout: int = typer.Option(10, "--timeout"),
|
||||||
|
json_output: bool = typer.Option(False, "--json"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
from reyna_cli.speech_live import SpeechLiveSession
|
||||||
|
|
||||||
|
with SpeechLiveSession(locale) as session:
|
||||||
|
emit(session.transcribe_chunk(audio.expanduser().read_bytes(), timeout=timeout), json_output)
|
||||||
|
except Exception as exc:
|
||||||
|
fail(str(exc), json_output)
|
||||||
|
|
||||||
|
|
||||||
@kokoro_app.command("config")
|
@kokoro_app.command("config")
|
||||||
def kokoro_direct_config(json_output: bool = typer.Option(False, "--json")):
|
def kokoro_direct_config(json_output: bool = typer.Option(False, "--json")):
|
||||||
try:
|
try:
|
||||||
@@ -1463,6 +1774,57 @@ def kokoro_direct_config(json_output: bool = typer.Option(False, "--json")):
|
|||||||
fail(str(exc), json_output)
|
fail(str(exc), json_output)
|
||||||
|
|
||||||
|
|
||||||
|
@kokoro_app.command("generate")
|
||||||
|
def kokoro_direct_generate(
|
||||||
|
text: str,
|
||||||
|
out: Optional[Path] = typer.Option(None, "--out"),
|
||||||
|
voice: Optional[str] = typer.Option(None, "--voice"),
|
||||||
|
speed: float = typer.Option(1.0, "--speed"),
|
||||||
|
lang_code: Optional[str] = typer.Option(None, "--lang"),
|
||||||
|
json_output: bool = typer.Option(False, "--json"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
from reyna_cli.media_execution import generate_kokoro
|
||||||
|
|
||||||
|
emit(generate_kokoro(text, out, voice=voice, speed=speed, lang_code=lang_code), json_output)
|
||||||
|
except Exception as exc:
|
||||||
|
fail(str(exc), json_output)
|
||||||
|
|
||||||
|
|
||||||
|
@qwen3_tts_app.command("config")
|
||||||
|
def qwen3_tts_direct_config(json_output: bool = typer.Option(False, "--json")):
|
||||||
|
try:
|
||||||
|
from reyna_cli.qwen_tts_direct import Qwen3TTSDirectClient
|
||||||
|
|
||||||
|
emit({"ok": True, "result": Qwen3TTSDirectClient().config_status()}, json_output)
|
||||||
|
except Exception as exc:
|
||||||
|
fail(str(exc), json_output)
|
||||||
|
|
||||||
|
|
||||||
|
@qwen3_tts_app.command("generate")
|
||||||
|
def qwen3_tts_direct_generate(
|
||||||
|
text: str,
|
||||||
|
out: Path = typer.Option(Path("~/Projects/MacMiniMCP/generated-audio/reyna-qwen-jv.wav"), "--out"),
|
||||||
|
ref_audio: Optional[Path] = typer.Option(None, "--ref-audio"),
|
||||||
|
ref_text: Optional[str] = typer.Option(None, "--ref-text"),
|
||||||
|
instruct: Optional[str] = typer.Option(None, "--instruct", help="Emotion/style instruction for delivery."),
|
||||||
|
temperature: float = typer.Option(0.3, "--temperature"),
|
||||||
|
json_output: bool = typer.Option(False, "--json"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
from reyna_cli.qwen_tts_direct import Qwen3TTSDirectClient
|
||||||
|
|
||||||
|
client = Qwen3TTSDirectClient(
|
||||||
|
ref_audio=ref_audio,
|
||||||
|
ref_text=ref_text,
|
||||||
|
instruct=instruct,
|
||||||
|
temperature=temperature,
|
||||||
|
)
|
||||||
|
emit(client.generate(text, out), json_output)
|
||||||
|
except Exception as exc:
|
||||||
|
fail(str(exc), json_output)
|
||||||
|
|
||||||
|
|
||||||
@voicebox_direct_app.command("config")
|
@voicebox_direct_app.command("config")
|
||||||
def voicebox_direct_config(json_output: bool = typer.Option(False, "--json")):
|
def voicebox_direct_config(json_output: bool = typer.Option(False, "--json")):
|
||||||
try:
|
try:
|
||||||
@@ -1473,11 +1835,52 @@ def voicebox_direct_config(json_output: bool = typer.Option(False, "--json")):
|
|||||||
fail(str(exc), json_output)
|
fail(str(exc), json_output)
|
||||||
|
|
||||||
|
|
||||||
|
@voicebox_direct_app.command("generate")
|
||||||
|
def voicebox_direct_generate(
|
||||||
|
text: str,
|
||||||
|
out: Optional[Path] = typer.Option(None, "--out"),
|
||||||
|
profile: str = typer.Option("Aiden", "--profile"),
|
||||||
|
engine: Optional[str] = typer.Option(None, "--engine"),
|
||||||
|
language: str = typer.Option("en", "--language"),
|
||||||
|
json_output: bool = typer.Option(False, "--json"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
from reyna_cli.media_execution import generate_voicebox
|
||||||
|
|
||||||
|
emit(generate_voicebox(text, out, profile=profile, engine=engine, language=language), json_output)
|
||||||
|
except Exception as exc:
|
||||||
|
fail(str(exc), json_output)
|
||||||
|
|
||||||
|
|
||||||
|
@unified_voice_app.command("config")
|
||||||
|
def unified_voice_config(json_output: bool = typer.Option(False, "--json")):
|
||||||
|
"""Report engine-specific configuration without loading models."""
|
||||||
|
emit({"ok": True, "result": UnifiedVoiceClient().config_status()}, json_output)
|
||||||
|
|
||||||
|
|
||||||
|
@unified_voice_app.command("generate")
|
||||||
|
def unified_voice_generate(
|
||||||
|
text: str,
|
||||||
|
engine: str = typer.Option("kokoro", "--engine", help="kokoro, pocket, or qwen3-tts"),
|
||||||
|
out: Path = typer.Option(Path("~/Projects/MacMiniMCP/generated-audio/reyna-voice.wav"), "--out"),
|
||||||
|
voice: Optional[str] = typer.Option(None, "--voice"),
|
||||||
|
speed: float = typer.Option(1.0, "--speed"),
|
||||||
|
lang_code: Optional[str] = typer.Option(None, "--lang"),
|
||||||
|
instruct: Optional[str] = typer.Option(None, "--instruct"),
|
||||||
|
temperature: float = typer.Option(0.3, "--temperature"),
|
||||||
|
json_output: bool = typer.Option(False, "--json"),
|
||||||
|
):
|
||||||
|
"""Generate a WAV with one explicit engine; engine voices never cross-map."""
|
||||||
|
try:
|
||||||
|
emit(UnifiedVoiceClient().generate(engine, text, out, voice=voice, speed=speed, lang_code=lang_code, instruct=instruct, temperature=temperature), json_output)
|
||||||
|
except Exception as exc:
|
||||||
|
fail(str(exc), json_output)
|
||||||
|
|
||||||
|
|
||||||
@apple_llm_app.command("config")
|
@apple_llm_app.command("config")
|
||||||
def apple_llm_direct_config(json_output: bool = typer.Option(False, "--json")):
|
def apple_llm_direct_config(json_output: bool = typer.Option(False, "--json")):
|
||||||
try:
|
try:
|
||||||
from reyna_cli.local_services_direct import AppleLLMDirectClient
|
from reyna_cli.local_services_direct import AppleLLMDirectClient
|
||||||
|
|
||||||
emit({"ok": True, "source": "direct", "result": AppleLLMDirectClient().config_status()}, json_output)
|
emit({"ok": True, "source": "direct", "result": AppleLLMDirectClient().config_status()}, json_output)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
fail(str(exc), json_output)
|
fail(str(exc), json_output)
|
||||||
@@ -1488,17 +1891,91 @@ def apple_llm_direct_check(json_output: bool = typer.Option(False, "--json")):
|
|||||||
# Prefer native privacy host probe (A), but also allow offline config
|
# Prefer native privacy host probe (A), but also allow offline config
|
||||||
try:
|
try:
|
||||||
from reyna_cli.privacy_host import native_apple_llm_check
|
from reyna_cli.privacy_host import native_apple_llm_check
|
||||||
|
|
||||||
emit(native_apple_llm_check(), json_output)
|
emit(native_apple_llm_check(), json_output)
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
try:
|
||||||
from reyna_cli.local_services_direct import AppleLLMDirectClient
|
from reyna_cli.local_services_direct import AppleLLMDirectClient
|
||||||
|
|
||||||
emit({"ok": True, "source": "direct_offline", "result": AppleLLMDirectClient().config_status()}, json_output)
|
emit({"ok": True, "source": "direct_offline", "result": AppleLLMDirectClient().config_status()}, json_output)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
fail(str(exc), json_output)
|
fail(str(exc), json_output)
|
||||||
|
|
||||||
|
|
||||||
|
@apple_llm_app.command("polish")
|
||||||
|
def apple_llm_polish(
|
||||||
|
text: str,
|
||||||
|
prev1: Optional[str] = typer.Option(None, "--prev1"),
|
||||||
|
prev2: Optional[str] = typer.Option(None, "--prev2"),
|
||||||
|
mode: str = typer.Option("line", "--mode"),
|
||||||
|
json_output: bool = typer.Option(False, "--json"),
|
||||||
|
):
|
||||||
|
"""Polish text using Apple ANE 3B LLM. Modes: line, paragraph."""
|
||||||
|
try:
|
||||||
|
session = get_apple_llm_session()
|
||||||
|
res = session.call({
|
||||||
|
"mode": mode,
|
||||||
|
"text": text,
|
||||||
|
"prev1": prev1 or "",
|
||||||
|
"prev2": prev2 or "",
|
||||||
|
})
|
||||||
|
emit(res, json_output)
|
||||||
|
except Exception as exc:
|
||||||
|
fail(str(exc), json_output)
|
||||||
|
|
||||||
|
|
||||||
|
@apple_llm_app.command("quick-reply")
|
||||||
|
def apple_llm_quick_reply(
|
||||||
|
draft: str,
|
||||||
|
context: Optional[str] = typer.Option(None, "--context"),
|
||||||
|
instructions: Optional[str] = typer.Option(None, "--instructions"),
|
||||||
|
json_output: bool = typer.Option(False, "--json"),
|
||||||
|
):
|
||||||
|
"""Generate a contextual instant reply preview."""
|
||||||
|
try:
|
||||||
|
session = get_apple_llm_session()
|
||||||
|
res = session.call({
|
||||||
|
"mode": "quick_reply",
|
||||||
|
"text": draft,
|
||||||
|
"context": context or "",
|
||||||
|
"instructions": instructions or "",
|
||||||
|
})
|
||||||
|
emit(res, json_output)
|
||||||
|
except Exception as exc:
|
||||||
|
fail(str(exc), json_output)
|
||||||
|
|
||||||
|
|
||||||
|
@apple_llm_app.command("chat")
|
||||||
|
def apple_llm_chat(
|
||||||
|
text: str,
|
||||||
|
history: Optional[str] = typer.Option(None, "--history", help="JSON array of messages"),
|
||||||
|
instructions: Optional[str] = typer.Option(None, "--instructions"),
|
||||||
|
json_output: bool = typer.Option(False, "--json"),
|
||||||
|
):
|
||||||
|
"""Chat with the on-device LLM."""
|
||||||
|
try:
|
||||||
|
session = get_apple_llm_session()
|
||||||
|
res = session.call({
|
||||||
|
"mode": "chat",
|
||||||
|
"text": text,
|
||||||
|
"history": history or "",
|
||||||
|
"instructions": instructions or "",
|
||||||
|
})
|
||||||
|
emit(res, json_output)
|
||||||
|
except Exception as exc:
|
||||||
|
fail(str(exc), json_output)
|
||||||
|
|
||||||
|
|
||||||
|
@apple_llm_app.command("status")
|
||||||
|
def apple_llm_status_cmd(json_output: bool = typer.Option(False, "--json")):
|
||||||
|
"""Show Apple LLM session status."""
|
||||||
|
emit(apple_llm_status(), json_output)
|
||||||
|
|
||||||
|
|
||||||
|
@apple_llm_app.command("close")
|
||||||
|
def apple_llm_close_cmd(json_output: bool = typer.Option(False, "--json")):
|
||||||
|
"""Close the Apple LLM session and free resources."""
|
||||||
|
emit(apple_llm_close(), json_output)
|
||||||
|
|
||||||
|
|
||||||
@image_direct_app.command("config")
|
@image_direct_app.command("config")
|
||||||
def image_direct_config(json_output: bool = typer.Option(False, "--json")):
|
def image_direct_config(json_output: bool = typer.Option(False, "--json")):
|
||||||
try:
|
try:
|
||||||
@@ -1509,6 +1986,34 @@ def image_direct_config(json_output: bool = typer.Option(False, "--json")):
|
|||||||
fail(str(exc), json_output)
|
fail(str(exc), json_output)
|
||||||
|
|
||||||
|
|
||||||
|
@image_direct_app.command("generate")
|
||||||
|
def image_direct_generate(
|
||||||
|
prompt: str,
|
||||||
|
engine: str = typer.Option("codex", "--engine", help="codex or gemini"),
|
||||||
|
out: Path = typer.Option(Path("~/Projects/MacMiniMCP/generated-images/reyna-image.png"), "--out"),
|
||||||
|
model: str = typer.Option("gemini-3.1-flash-image", "--model"),
|
||||||
|
aspect_ratio: Optional[str] = typer.Option(None, "--aspect-ratio"),
|
||||||
|
image_size: Optional[str] = typer.Option(None, "--image-size"),
|
||||||
|
reference_image: Optional[Path] = typer.Option(None, "--reference-image"),
|
||||||
|
json_output: bool = typer.Option(False, "--json"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
from reyna_cli.media_execution import generate_codex_image, generate_gemini_image
|
||||||
|
|
||||||
|
if engine.lower() == "gemini":
|
||||||
|
result = generate_gemini_image(prompt, out, model=model, aspect_ratio=aspect_ratio, image_size=image_size)
|
||||||
|
elif engine.lower() == "codex":
|
||||||
|
result = generate_codex_image(prompt, out, reference_image=reference_image)
|
||||||
|
else:
|
||||||
|
fail("--engine must be codex or gemini", json_output)
|
||||||
|
return
|
||||||
|
emit(result, json_output)
|
||||||
|
except typer.Exit:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
fail(str(exc), json_output)
|
||||||
|
|
||||||
|
|
||||||
@system_direct_app.command("config")
|
@system_direct_app.command("config")
|
||||||
def system_direct_config(json_output: bool = typer.Option(False, "--json")):
|
def system_direct_config(json_output: bool = typer.Option(False, "--json")):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ class AppleLLMDirectClient:
|
|||||||
m = mode if mode in ("line","paragraph","quick_reply","chat","check") else "line"
|
m = mode if mode in ("line","paragraph","quick_reply","chat","check") else "line"
|
||||||
return {"text": text[:5000], "mode": m, "offline_validation": True}
|
return {"text": text[:5000], "mode": m, "offline_validation": True}
|
||||||
|
|
||||||
# ─── Image gen (out of scope but config only) ───────────────────────────────
|
# ─── Image generation ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
class ImageDirectClient:
|
class ImageDirectClient:
|
||||||
def config_status(self) -> Dict[str, Any]:
|
def config_status(self) -> Dict[str, Any]:
|
||||||
@@ -206,6 +206,16 @@ class ImageDirectClient:
|
|||||||
"note": "config_status only, no image generation, no key exposure",
|
"note": "config_status only, no image generation, no key exposure",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def generate_gemini(self, prompt: str, output: Optional[Path] = None, **kwargs: Any) -> Dict[str, Any]:
|
||||||
|
from reyna_cli.media_execution import generate_gemini_image
|
||||||
|
|
||||||
|
return generate_gemini_image(prompt, output, **kwargs)
|
||||||
|
|
||||||
|
def generate_codex(self, prompt: str, output: Path, **kwargs: Any) -> Dict[str, Any]:
|
||||||
|
from reyna_cli.media_execution import generate_codex_image
|
||||||
|
|
||||||
|
return generate_codex_image(prompt, output, **kwargs)
|
||||||
|
|
||||||
# ─── System info (offline safe, no TCC) ───────────────────────────────────
|
# ─── System info (offline safe, no TCC) ───────────────────────────────────
|
||||||
|
|
||||||
class SystemDirectClient:
|
class SystemDirectClient:
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"""Direct execution clients for Reyna-owned speech, audio, and image flows.
|
||||||
|
|
||||||
|
These paths intentionally live outside the signed privacy host: they use local
|
||||||
|
media services, subprocesses, or explicitly configured external APIs and do not
|
||||||
|
need Apple Automation permissions.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from reyna_cli.env import load_hermes_env
|
||||||
|
from reyna_cli.tts import synthesize_wav
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_filename(name: Optional[str], suffix: str) -> str:
|
||||||
|
raw = Path(name or f"reyna-{int(time.time() * 1000)}-{uuid.uuid4().hex[:6]}{suffix}").name
|
||||||
|
cleaned = "".join(c if c.isalnum() or c in "._-" else "-" for c in raw)
|
||||||
|
if not cleaned:
|
||||||
|
cleaned = f"reyna-{uuid.uuid4().hex[:8]}{suffix}"
|
||||||
|
if not cleaned.lower().endswith(suffix):
|
||||||
|
cleaned += suffix
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def _json_request(url: str, payload: Optional[Dict[str, Any]] = None, *, headers: Optional[Dict[str, str]] = None, timeout: float = 30.0) -> Any:
|
||||||
|
data = None if payload is None else json.dumps(payload).encode("utf-8")
|
||||||
|
req = urllib.request.Request(url, data=data, headers={"content-type": "application/json", **(headers or {})}, method="POST" if data is not None else "GET")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as response:
|
||||||
|
raw = response.read()
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = exc.read().decode("utf-8", errors="replace")[:500]
|
||||||
|
raise RuntimeError(f"HTTP {exc.code}: {detail}") from exc
|
||||||
|
try:
|
||||||
|
return json.loads(raw.decode("utf-8"))
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise RuntimeError(f"Invalid JSON response from {url}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def generate_local_tts(text: str, output: Path, *, voice: Optional[str] = None, rate: Optional[int] = None, sample_rate: int = 16000) -> Dict[str, Any]:
|
||||||
|
"""Generate a real PCM WAV using the local Reyna TTS selection chain."""
|
||||||
|
load_hermes_env()
|
||||||
|
result = synthesize_wav(text, output, voice=voice, sample_rate=sample_rate)
|
||||||
|
return {"ok": True, "source": "reyna_cli_direct", "filePath": str(result), "format": "wav pcm", "sampleRate": sample_rate, "voice": voice or os.environ.get("REYNA_TTS_VOICE") or "default", "rate": rate}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_kokoro(text: str, output: Optional[Path] = None, *, url: Optional[str] = None, voice: Optional[str] = None, speed: float = 1.0, lang_code: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
load_hermes_env()
|
||||||
|
base = (url or os.environ.get("KSAY_URL") or "http://127.0.0.1:7332").rstrip("/")
|
||||||
|
destination = output.expanduser() if output else Path.home() / "Projects" / "MacMiniMCP" / "generated-audio" / f"ksay-{int(time.time() * 1000)}.wav"
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
payload = {"text": text, "voice": voice or os.environ.get("KSAY_VOICE") or "af_heart", "speed": speed, "langCode": lang_code or os.environ.get("KSAY_LANG_CODE") or "a", "output": str(destination)}
|
||||||
|
response = _json_request(f"{base}/say", payload, timeout=120.0)
|
||||||
|
path_value = response.get("filePath") if isinstance(response, dict) else None
|
||||||
|
source_path = Path(path_value).expanduser() if path_value else destination
|
||||||
|
if source_path.exists() and source_path != destination:
|
||||||
|
shutil.copyfile(source_path, destination)
|
||||||
|
if not destination.exists() or destination.stat().st_size == 0:
|
||||||
|
raise RuntimeError("Kokoro completed without creating an audio file")
|
||||||
|
return {**(response if isinstance(response, dict) else {}), "ok": True, "source": "reyna_cli_direct", "filePath": str(destination)}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_voicebox(text: str, output: Optional[Path] = None, *, url: Optional[str] = None, profile: str = "Aiden", engine: Optional[str] = None, language: str = "en", timeout: float = 120.0) -> Dict[str, Any]:
|
||||||
|
load_hermes_env()
|
||||||
|
base = (url or os.environ.get("VOICEBOX_URL") or "http://127.0.0.1:17493").rstrip("/")
|
||||||
|
body: Dict[str, Any] = {"profile_id": profile, "text": text[:1000], "language": language}
|
||||||
|
if engine:
|
||||||
|
body["engine"] = engine
|
||||||
|
response = _json_request(f"{base}/generate", body, timeout=timeout)
|
||||||
|
if not isinstance(response, dict):
|
||||||
|
raise RuntimeError("Voicebox returned an unexpected response")
|
||||||
|
started = time.monotonic()
|
||||||
|
while response.get("status") == "generating" and time.monotonic() - started < timeout:
|
||||||
|
time.sleep(0.5)
|
||||||
|
response = _json_request(f"{base}/history/{response.get('id')}", None, timeout=30.0)
|
||||||
|
if response.get("status") == "failed":
|
||||||
|
raise RuntimeError(str(response.get("error") or "Voicebox generation failed"))
|
||||||
|
audio_path = response.get("audio_path")
|
||||||
|
if not audio_path:
|
||||||
|
raise RuntimeError("Voicebox returned no audio_path")
|
||||||
|
source = Path(str(audio_path)).expanduser()
|
||||||
|
candidates = [source]
|
||||||
|
if not source.is_absolute():
|
||||||
|
root = Path.home() / "Library" / "Application Support" / "sh.voicebox.app"
|
||||||
|
candidates.extend([root / source, root / "generations" / source.name])
|
||||||
|
source = next((candidate for candidate in candidates if candidate.exists()), source)
|
||||||
|
if not source.exists():
|
||||||
|
raise RuntimeError(f"Voicebox audio file not found: {audio_path}")
|
||||||
|
destination = output.expanduser() if output else Path.home() / "Projects" / "MacMiniMCP" / "generated-audio" / source.name
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copyfile(source, destination)
|
||||||
|
return {**response, "ok": True, "source": "reyna_cli_direct", "filePath": str(destination)}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_gemini_image(prompt: str, output: Optional[Path] = None, *, model: str = "gemini-3.1-flash-image", aspect_ratio: Optional[str] = None, image_size: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
load_hermes_env()
|
||||||
|
key = os.environ.get("GEMINI_API_KEY")
|
||||||
|
if not key:
|
||||||
|
raise RuntimeError("GEMINI_API_KEY is required")
|
||||||
|
response_format: Dict[str, Any] = {"type": "image", "mime_type": "image/png"}
|
||||||
|
if aspect_ratio:
|
||||||
|
response_format["aspect_ratio"] = aspect_ratio
|
||||||
|
if image_size:
|
||||||
|
response_format["image_size"] = image_size
|
||||||
|
body = {"model": model, "input": [{"type": "text", "text": prompt}], "response_format": response_format}
|
||||||
|
response = _json_request("https://generativelanguage.googleapis.com/v1beta/interactions", body, headers={"x-goog-api-key": key}, timeout=180.0)
|
||||||
|
image = response.get("output_image") if isinstance(response, dict) else None
|
||||||
|
if not isinstance(image, dict) or not image.get("data"):
|
||||||
|
raise RuntimeError("Gemini API did not return output_image.data")
|
||||||
|
destination = output.expanduser() if output else Path.home() / "Projects" / "MacMiniMCP" / "generated-images" / _safe_filename(None, ".png")
|
||||||
|
destination = destination.with_suffix(".png")
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
destination.write_bytes(base64.b64decode(image["data"]))
|
||||||
|
return {"ok": True, "source": "reyna_cli_direct", "path": str(destination), "model": model, "mimeType": image.get("mime_type", "image/png"), "interactionId": response.get("id")}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_codex_image(prompt: str, output: Path, *, size: Optional[str] = None, quality: Optional[str] = None, style: Optional[str] = None, reference_image: Optional[Path] = None, codex_path: Optional[str] = None, timeout: int = 600) -> Dict[str, Any]:
|
||||||
|
destination = output.expanduser().resolve()
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
codex = codex_path or os.environ.get("CODEX_CLI_PATH") or "codex"
|
||||||
|
details = "\n".join(x for x in [f"Requested size/aspect: {size}" if size else "", f"Requested quality: {quality}" if quality else "", f"Requested style: {style}" if style else ""])
|
||||||
|
worker_prompt = "\n\n".join(x for x in ["Use $imagegen to generate exactly one raster image.", f"Save the final image file at this exact absolute path: {destination}", "Do not modify any other files.", f"Image prompt:\n{prompt}", details] if x)
|
||||||
|
args = ["exec", "--ephemeral", "--sandbox", "workspace-write", "--enable", "image_generation", "-C", str(Path.cwd())]
|
||||||
|
if reference_image:
|
||||||
|
args.extend(["--image", str(reference_image.expanduser())])
|
||||||
|
args.append(worker_prompt)
|
||||||
|
result = subprocess.run([codex, *args], capture_output=True, text=True, timeout=timeout, check=False)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(f"Codex image generation failed: {(result.stderr or result.stdout).strip()[:1000]}")
|
||||||
|
if not destination.exists() or destination.stat().st_size == 0:
|
||||||
|
raise RuntimeError(f"Codex completed but did not create {destination}")
|
||||||
|
return {"ok": True, "source": "reyna_cli_direct", "path": str(destination), "bytes": destination.stat().st_size, "codexCliPath": codex}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
"""Direct Apple Notes automation for the mutable Reyna CLI.
|
||||||
|
|
||||||
|
The scripts are static JXA programs. User data is passed only as one JSON
|
||||||
|
argument to ``osascript`` so note content is never interpolated into source.
|
||||||
|
This deliberately stays outside the stable Swift host: Python-only CLI changes
|
||||||
|
do not require rebuilding or re-signing Reyna CLI.app.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from typing import Any, Callable, Optional, Sequence
|
||||||
|
|
||||||
|
|
||||||
|
class NotesAutomationError(RuntimeError):
|
||||||
|
"""Apple Notes could not complete the requested automation operation."""
|
||||||
|
|
||||||
|
|
||||||
|
Runner = Callable[..., Any]
|
||||||
|
|
||||||
|
|
||||||
|
_NOTES_LIST_SCRIPT = r'''function run(argv) {
|
||||||
|
const input = JSON.parse(argv[0]);
|
||||||
|
const app = Application("/System/Applications/Notes.app");
|
||||||
|
const query = (input.query || "").toLowerCase();
|
||||||
|
const folderName = input.folder || "";
|
||||||
|
const found = [];
|
||||||
|
const accounts = app.accounts();
|
||||||
|
for (let a = 0; a < accounts.length && found.length < input.limit; a++) {
|
||||||
|
const folders = accounts[a].folders();
|
||||||
|
for (let f = 0; f < folders.length && found.length < input.limit; f++) {
|
||||||
|
const folder = folders[f];
|
||||||
|
const currentFolder = String(folder.name());
|
||||||
|
if (folderName && currentFolder !== folderName) continue;
|
||||||
|
const notes = folder.notes();
|
||||||
|
for (let n = 0; n < notes.length && found.length < input.limit; n++) {
|
||||||
|
const note = notes[n];
|
||||||
|
let title = "";
|
||||||
|
let text = "";
|
||||||
|
try { title = String(note.name()); } catch (_) {}
|
||||||
|
try { text = String(note.plaintext()); } catch (_) {}
|
||||||
|
if (query && (title + "\n" + text).toLowerCase().indexOf(query) === -1) continue;
|
||||||
|
found.push({
|
||||||
|
id: String(note.id()),
|
||||||
|
title: title,
|
||||||
|
folder: currentFolder,
|
||||||
|
modifiedAt: note.modificationDate().toISOString(),
|
||||||
|
preview: input.includePreview ? text.slice(0, 180) : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return JSON.stringify(found);
|
||||||
|
}'''
|
||||||
|
|
||||||
|
_NOTES_READ_SCRIPT = r'''function run(argv) {
|
||||||
|
const input = JSON.parse(argv[0]);
|
||||||
|
const app = Application("/System/Applications/Notes.app");
|
||||||
|
const accounts = app.accounts();
|
||||||
|
for (let a = 0; a < accounts.length; a++) {
|
||||||
|
const folders = accounts[a].folders();
|
||||||
|
for (let f = 0; f < folders.length; f++) {
|
||||||
|
const notes = folders[f].notes();
|
||||||
|
for (let n = 0; n < notes.length; n++) {
|
||||||
|
const note = notes[n];
|
||||||
|
if (String(note.id()) === input.id) {
|
||||||
|
return JSON.stringify({
|
||||||
|
id: String(note.id()),
|
||||||
|
title: String(note.name()),
|
||||||
|
folder: String(folders[f].name()),
|
||||||
|
bodyHtml: String(note.body()),
|
||||||
|
plaintext: String(note.plaintext()),
|
||||||
|
createdAt: note.creationDate().toISOString(),
|
||||||
|
modifiedAt: note.modificationDate().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("Note not found");
|
||||||
|
}'''
|
||||||
|
|
||||||
|
_NOTES_CREATE_SCRIPT = r'''function run(argv) {
|
||||||
|
const input = JSON.parse(argv[0]);
|
||||||
|
const app = Application("/System/Applications/Notes.app");
|
||||||
|
const escapeHtml = value => String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
const body = escapeHtml(input.body).replace(/\n/g, "<br>");
|
||||||
|
const html = "<h1>" + escapeHtml(input.title) + "</h1><div>" + body + "</div>";
|
||||||
|
let destination = null;
|
||||||
|
const accounts = app.accounts();
|
||||||
|
for (let a = 0; a < accounts.length && !destination; a++) {
|
||||||
|
const folders = accounts[a].folders();
|
||||||
|
for (let f = 0; f < folders.length; f++) {
|
||||||
|
if (!input.folder || String(folders[f].name()) === input.folder) {
|
||||||
|
destination = folders[f];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!destination) throw new Error("Notes destination folder not found");
|
||||||
|
const note = app.Note({body: html});
|
||||||
|
destination.notes.push(note);
|
||||||
|
return JSON.stringify({id: String(note.id()), title: String(note.name()), folder: String(destination.name())});
|
||||||
|
}'''
|
||||||
|
|
||||||
|
|
||||||
|
def _run_jxa(script: str, payload: dict[str, Any], *, runner: Runner = subprocess.run) -> Any:
|
||||||
|
arguments: Sequence[str] = [
|
||||||
|
"/usr/bin/osascript",
|
||||||
|
"-l",
|
||||||
|
"JavaScript",
|
||||||
|
"-e",
|
||||||
|
script,
|
||||||
|
"--",
|
||||||
|
json.dumps(payload, separators=(",", ":")),
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
result = runner(arguments, capture_output=True, text=True, timeout=30, check=False)
|
||||||
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||||
|
raise NotesAutomationError(f"Apple Notes automation could not start: {exc}") from exc
|
||||||
|
if result.returncode:
|
||||||
|
detail = (result.stderr or result.stdout or "Apple Notes automation failed").strip()
|
||||||
|
raise NotesAutomationError(detail[:1000])
|
||||||
|
try:
|
||||||
|
return json.loads((result.stdout or "").strip())
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise NotesAutomationError("Apple Notes automation returned invalid JSON") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_text(value: str, field: str, maximum: int) -> str:
|
||||||
|
value = value.strip()
|
||||||
|
if not value:
|
||||||
|
raise ValueError(f"{field} is required")
|
||||||
|
if len(value) > maximum:
|
||||||
|
raise ValueError(f"{field} exceeds maximum length {maximum}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def list_notes(
|
||||||
|
*,
|
||||||
|
query: Optional[str] = None,
|
||||||
|
folder: Optional[str] = None,
|
||||||
|
include_preview: bool = False,
|
||||||
|
limit: int = 20,
|
||||||
|
runner: Runner = subprocess.run,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
if not 1 <= limit <= 100:
|
||||||
|
raise ValueError("limit must be between 1 and 100")
|
||||||
|
result = _run_jxa(
|
||||||
|
_NOTES_LIST_SCRIPT,
|
||||||
|
{"query": query or "", "folder": folder or "", "includePreview": include_preview, "limit": limit},
|
||||||
|
runner=runner,
|
||||||
|
)
|
||||||
|
if not isinstance(result, list):
|
||||||
|
raise NotesAutomationError("Apple Notes list response was not a list")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def read_note(note_id: str, *, runner: Runner = subprocess.run) -> dict[str, Any]:
|
||||||
|
result = _run_jxa(_NOTES_READ_SCRIPT, {"id": _bounded_text(note_id, "note id", 1024)}, runner=runner)
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
raise NotesAutomationError("Apple Notes read response was not an object")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def create_note(
|
||||||
|
title: str,
|
||||||
|
body: str = "",
|
||||||
|
*,
|
||||||
|
folder: Optional[str] = None,
|
||||||
|
runner: Runner = subprocess.run,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
title = _bounded_text(title, "title", 500)
|
||||||
|
if len(body) > 100_000:
|
||||||
|
raise ValueError("body exceeds maximum length 100000")
|
||||||
|
result = _run_jxa(_NOTES_CREATE_SCRIPT, {"title": title, "body": body, "folder": folder or ""}, runner=runner)
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
raise NotesAutomationError("Apple Notes create response was not an object")
|
||||||
|
return result
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""Direct local Qwen3-TTS voice-cloning support for reyna-cli.
|
||||||
|
|
||||||
|
This follows the QwenTTSService implementation pulled from the VoiceAgent
|
||||||
|
Gitea repository, but exposes it as a synchronous CLI client and keeps the
|
||||||
|
MLX imports lazy so config/help commands remain offline-safe.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import wave
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
MODEL_ID = "mlx-community/Qwen3-TTS-12Hz-0.6B-Base-bf16"
|
||||||
|
DEFAULT_REF_AUDIO = Path.home() / "Downloads" / "jv_voice_sample.wav"
|
||||||
|
JV_SAMPLE_TEXT = (
|
||||||
|
"I have completed a diagnostic scan of your current schedule, and it appears "
|
||||||
|
"several conflicts have arisen. While I have taken the liberty of reorganizing "
|
||||||
|
"your morning appointments to ensure maximum efficiency, I cannot account for "
|
||||||
|
"human fatigue. Perhaps a second cup of coffee would be a logical next step."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Qwen3TTSDirectClient:
|
||||||
|
"""Generate JV-cloned speech with the local MLX Qwen3-TTS Base model."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
model_id: Optional[str] = None,
|
||||||
|
ref_audio: Optional[Path] = None,
|
||||||
|
ref_text: Optional[str] = None,
|
||||||
|
instruct: Optional[str] = None,
|
||||||
|
temperature: float = 0.3,
|
||||||
|
) -> None:
|
||||||
|
self.model_id = (
|
||||||
|
model_id
|
||||||
|
or os.environ.get("REYNA_QWEN3_TTS_MODEL")
|
||||||
|
or MODEL_ID
|
||||||
|
).strip()
|
||||||
|
configured_ref = os.environ.get("REYNA_QWEN3_TTS_REF_AUDIO")
|
||||||
|
self.ref_audio = Path(
|
||||||
|
ref_audio or configured_ref or DEFAULT_REF_AUDIO
|
||||||
|
).expanduser()
|
||||||
|
self.ref_text = (
|
||||||
|
ref_text
|
||||||
|
if ref_text is not None
|
||||||
|
else os.environ.get("REYNA_QWEN3_TTS_REF_TEXT") or JV_SAMPLE_TEXT
|
||||||
|
).strip()
|
||||||
|
self.instruct = (
|
||||||
|
instruct
|
||||||
|
if instruct is not None
|
||||||
|
else os.environ.get("REYNA_QWEN3_TTS_INSTRUCT")
|
||||||
|
)
|
||||||
|
if self.instruct is not None:
|
||||||
|
self.instruct = self.instruct.strip()[:2000] or None
|
||||||
|
self.temperature = float(temperature)
|
||||||
|
if not 0.0 < self.temperature <= 2.0:
|
||||||
|
raise ValueError("temperature must be > 0 and <= 2.0")
|
||||||
|
self._model: Any = None
|
||||||
|
|
||||||
|
def config_status(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"engine": "qwen3-tts",
|
||||||
|
"model_id": self.model_id,
|
||||||
|
"ref_audio": str(self.ref_audio),
|
||||||
|
"ref_audio_exists": self.ref_audio.is_file(),
|
||||||
|
"ref_audio_size_bytes": self.ref_audio.stat().st_size
|
||||||
|
if self.ref_audio.is_file()
|
||||||
|
else None,
|
||||||
|
"ref_text_configured": bool(self.ref_text),
|
||||||
|
"instruct_configured": bool(self.instruct),
|
||||||
|
"temperature": self.temperature,
|
||||||
|
"source": "direct",
|
||||||
|
"offline": True,
|
||||||
|
"note": "Uses MLX locally; config_status does not load the model or contact a service.",
|
||||||
|
}
|
||||||
|
|
||||||
|
def validate_generate(self, text: str) -> Dict[str, Any]:
|
||||||
|
if not text or not text.strip():
|
||||||
|
raise ValueError("text required")
|
||||||
|
if not self.ref_audio.is_file():
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"reference audio not found: {self.ref_audio}"
|
||||||
|
)
|
||||||
|
if not self.ref_text:
|
||||||
|
raise ValueError("reference audio transcript required")
|
||||||
|
return {
|
||||||
|
"text": text.strip()[:8000],
|
||||||
|
"engine": "qwen3-tts",
|
||||||
|
"model_id": self.model_id,
|
||||||
|
"ref_audio": str(self.ref_audio),
|
||||||
|
"offline_validation": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _ensure_model_loaded(self) -> None:
|
||||||
|
if self._model is not None:
|
||||||
|
return
|
||||||
|
from mlx_audio.tts import load_model
|
||||||
|
|
||||||
|
self._model = load_model(self.model_id)
|
||||||
|
|
||||||
|
def generate(self, text: str, output: Path) -> Dict[str, Any]:
|
||||||
|
"""Generate a WAV file and return verified output metadata."""
|
||||||
|
self.validate_generate(text)
|
||||||
|
output = Path(output).expanduser()
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._ensure_model_loaded()
|
||||||
|
|
||||||
|
from mlx_audio.tts.generate import generate_audio
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="reyna-qwen3-") as workdir:
|
||||||
|
generate_audio(
|
||||||
|
text=text.strip()[:8000],
|
||||||
|
model=self._model,
|
||||||
|
ref_audio=str(self.ref_audio),
|
||||||
|
ref_text=self.ref_text,
|
||||||
|
instruct=self.instruct,
|
||||||
|
temperature=self.temperature,
|
||||||
|
output_path=workdir,
|
||||||
|
file_prefix="audio",
|
||||||
|
audio_format="wav",
|
||||||
|
verbose=False,
|
||||||
|
play=False,
|
||||||
|
)
|
||||||
|
candidates = sorted(Path(workdir).glob("audio*.wav"))
|
||||||
|
if not candidates:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Qwen3-TTS returned without creating a WAV file"
|
||||||
|
)
|
||||||
|
shutil.copyfile(candidates[0], output)
|
||||||
|
|
||||||
|
if not output.is_file() or output.stat().st_size <= 44:
|
||||||
|
raise RuntimeError(f"Qwen3-TTS output is missing or empty: {output}")
|
||||||
|
|
||||||
|
with wave.open(str(output), "rb") as wav:
|
||||||
|
sample_rate = wav.getframerate()
|
||||||
|
frames = wav.getnframes()
|
||||||
|
channels = wav.getnchannels()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"engine": "qwen3-tts",
|
||||||
|
"model_id": self.model_id,
|
||||||
|
"voice": "qwen_jv",
|
||||||
|
"ref_audio": str(self.ref_audio),
|
||||||
|
"path": str(output),
|
||||||
|
"size_bytes": output.stat().st_size,
|
||||||
|
"sample_rate": sample_rate,
|
||||||
|
"channels": channels,
|
||||||
|
"duration_seconds": round(frames / sample_rate, 3)
|
||||||
|
if sample_rate
|
||||||
|
else 0.0,
|
||||||
|
"source": "direct",
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Launch mutable Reyna CLI Python logic through the signed native app."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, Sequence
|
||||||
|
|
||||||
|
from reyna_cli.app_bundle import app_bundle_executable_path
|
||||||
|
|
||||||
|
|
||||||
|
class SignedLauncherError(RuntimeError):
|
||||||
|
"""The signed app required for a privileged Python launch is unavailable."""
|
||||||
|
|
||||||
|
|
||||||
|
def signed_app_executable() -> Path:
|
||||||
|
return app_bundle_executable_path()
|
||||||
|
|
||||||
|
|
||||||
|
def run_signed_python(
|
||||||
|
arguments: Sequence[str],
|
||||||
|
*,
|
||||||
|
executable: Path | None = None,
|
||||||
|
runner: Callable[..., object] = subprocess.run,
|
||||||
|
) -> int:
|
||||||
|
"""Run Python CLI arguments under the stable signed host and return its exit code."""
|
||||||
|
app_executable = Path(executable) if executable is not None else signed_app_executable()
|
||||||
|
if not app_executable.is_file():
|
||||||
|
raise SignedLauncherError(f"signed Reyna CLI app executable is missing: {app_executable}")
|
||||||
|
|
||||||
|
result = runner([str(app_executable), "--python", *arguments], check=False)
|
||||||
|
return int(getattr(result, "returncode", 1))
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Direct Apple SpeechTranscriber file execution for Reyna CLI."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
_SWIFT_SOURCE = r'''import Speech
|
||||||
|
import AVFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct ReynaTranscriber {
|
||||||
|
static func main() async {
|
||||||
|
let args = CommandLine.arguments
|
||||||
|
guard args.count >= 2 else { fail("audio path is required", code: 2) }
|
||||||
|
let audioPath = args[1]
|
||||||
|
let localeID = args.count >= 3 ? args[2] : "en-US"
|
||||||
|
guard FileManager.default.fileExists(atPath: audioPath) else { fail("audio file not found", code: 3) }
|
||||||
|
guard SpeechTranscriber.isAvailable else { fail("SpeechTranscriber is unavailable", code: 4) }
|
||||||
|
let requested = Locale(identifier: localeID)
|
||||||
|
guard let locale = await SpeechTranscriber.supportedLocale(equivalentTo: requested) else { fail("unsupported locale", code: 5) }
|
||||||
|
let transcriber = SpeechTranscriber(locale: locale, preset: .transcription)
|
||||||
|
do {
|
||||||
|
if let request = try await AssetInventory.assetInstallationRequest(supporting: [transcriber]) {
|
||||||
|
try await request.downloadAndInstall()
|
||||||
|
}
|
||||||
|
let file = try AVAudioFile(forReading: URL(fileURLWithPath: audioPath))
|
||||||
|
let analyzer = try await SpeechAnalyzer(inputAudioFile: file, modules: [transcriber], finishAfterFile: true)
|
||||||
|
_ = analyzer
|
||||||
|
var segments: [String] = []
|
||||||
|
for try await result in transcriber.results {
|
||||||
|
let text = String(result.text.characters).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !text.isEmpty { segments.append(text) }
|
||||||
|
}
|
||||||
|
let payload: [String: Any] = [
|
||||||
|
"ok": true,
|
||||||
|
"engine": "SpeechAnalyzer+SpeechTranscriber",
|
||||||
|
"locale": locale.identifier,
|
||||||
|
"requestedLocale": localeID,
|
||||||
|
"transcript": segments.joined(separator: " "),
|
||||||
|
"segments": segments,
|
||||||
|
"audioPath": audioPath,
|
||||||
|
"isAvailable": SpeechTranscriber.isAvailable
|
||||||
|
]
|
||||||
|
let data = try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys])
|
||||||
|
FileHandle.standardOutput.write(data)
|
||||||
|
} catch {
|
||||||
|
fail(String(describing: error), code: 6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static func fail(_ message: String, code: Int32) -> Never {
|
||||||
|
let payload: [String: Any] = ["ok": false, "error": message]
|
||||||
|
if let data = try? JSONSerialization.data(withJSONObject: payload, options: []) { FileHandle.standardOutput.write(data) }
|
||||||
|
exit(code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
class SpeechExecutionError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_dir() -> Path:
|
||||||
|
return Path.home() / "Library" / "Application Support" / "reyna-cli" / "speech"
|
||||||
|
|
||||||
|
|
||||||
|
def _binary_path() -> Path:
|
||||||
|
digest = hashlib.sha256(_SWIFT_SOURCE.encode()).hexdigest()[:16]
|
||||||
|
return _cache_dir() / f"transcriber-{digest}"
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_binary() -> Path:
|
||||||
|
cached = _binary_path()
|
||||||
|
if cached.exists() and os.access(cached, os.X_OK):
|
||||||
|
return cached
|
||||||
|
swiftc = shutil.which("swiftc") or "/usr/bin/swiftc"
|
||||||
|
if not Path(swiftc).exists():
|
||||||
|
raise SpeechExecutionError("swiftc is required for SpeechTranscriber file execution")
|
||||||
|
_cache_dir().mkdir(parents=True, exist_ok=True)
|
||||||
|
with tempfile.TemporaryDirectory(prefix="reyna-speech-build-") as tmp:
|
||||||
|
source = Path(tmp) / "Transcriber.swift"
|
||||||
|
binary = Path(tmp) / "transcriber"
|
||||||
|
source.write_text(_SWIFT_SOURCE, encoding="utf-8")
|
||||||
|
result = subprocess.run([swiftc, "-O", "-parse-as-library", str(source), "-o", str(binary), "-framework", "AVFoundation", "-framework", "Speech"], capture_output=True, text=True, timeout=120, check=False)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise SpeechExecutionError(f"swiftc failed: {(result.stderr or result.stdout).strip()[:2000]}")
|
||||||
|
shutil.copyfile(binary, cached)
|
||||||
|
cached.chmod(0o700)
|
||||||
|
return cached
|
||||||
|
|
||||||
|
|
||||||
|
def transcribe_file(audio_path: Path, *, locale: str = "en-US", timeout: int = 300) -> Dict[str, Any]:
|
||||||
|
audio = audio_path.expanduser().resolve()
|
||||||
|
if not audio.is_file():
|
||||||
|
raise SpeechExecutionError(f"audio file not found: {audio}")
|
||||||
|
binary = _ensure_binary()
|
||||||
|
result = subprocess.run([str(binary), str(audio), locale], capture_output=True, text=True, timeout=timeout, check=False)
|
||||||
|
raw = (result.stdout or "").strip()
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw) if raw else {}
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise SpeechExecutionError(f"SpeechTranscriber returned invalid JSON: {raw[:500]}") from exc
|
||||||
|
if result.returncode != 0 or not payload.get("ok"):
|
||||||
|
raise SpeechExecutionError(str(payload.get("error") or result.stderr.strip() or "SpeechTranscriber failed"))
|
||||||
|
return {**payload, "source": "reyna_cli_direct", "binary": str(binary)}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""Persistent direct SpeechAnalyzer pipe session for live audio chunks."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import shutil
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
_SWIFT_SOURCE = r'''import AVFoundation
|
||||||
|
import Foundation
|
||||||
|
import Speech
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct ReynaLiveTranscriber {
|
||||||
|
static func main() async {
|
||||||
|
let args = CommandLine.arguments
|
||||||
|
let localeID = args.count > 1 ? args[1] : "en-US"
|
||||||
|
guard SpeechTranscriber.isAvailable else { exit(4) }
|
||||||
|
guard let locale = await SpeechTranscriber.supportedLocale(equivalentTo: Locale(identifier: localeID)) else { exit(5) }
|
||||||
|
let warm = SpeechTranscriber(locale: locale, preset: .transcription)
|
||||||
|
do {
|
||||||
|
if let request = try await AssetInventory.assetInstallationRequest(supporting: [warm]) { try await request.downloadAndInstall() }
|
||||||
|
} catch { exit(6) }
|
||||||
|
while true {
|
||||||
|
guard let header = readExact(4) else { break }
|
||||||
|
let length = header.withUnsafeBytes { $0.load(as: UInt32.self).bigEndian }
|
||||||
|
if length == 0 || length > 20_000_000 { break }
|
||||||
|
guard let bytes = readExact(Int(length)) else { break }
|
||||||
|
let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("reyna-live-\(UUID().uuidString).wav")
|
||||||
|
do {
|
||||||
|
try bytes.write(to: url)
|
||||||
|
let file = try AVAudioFile(forReading: url)
|
||||||
|
let transcriber = SpeechTranscriber(locale: locale, preset: .transcription)
|
||||||
|
let analyzer = try await SpeechAnalyzer(inputAudioFile: file, modules: [transcriber], finishAfterFile: true)
|
||||||
|
_ = analyzer
|
||||||
|
var text = ""
|
||||||
|
for try await result in transcriber.results { text += " " + String(result.text.characters) }
|
||||||
|
let output: [String: Any] = ["ok": true, "event": "final", "text": text.trimmingCharacters(in: .whitespacesAndNewlines)]
|
||||||
|
let data = try JSONSerialization.data(withJSONObject: output, options: [])
|
||||||
|
FileHandle.standardOutput.write(data); FileHandle.standardOutput.write(Data([10]))
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
} catch {
|
||||||
|
let output: [String: Any] = ["ok": false, "error": String(describing: error)]
|
||||||
|
if let data = try? JSONSerialization.data(withJSONObject: output, options: []) { FileHandle.standardOutput.write(data); FileHandle.standardOutput.write(Data([10])) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static func readExact(_ count: Int) -> Data? {
|
||||||
|
var data = Data(); data.reserveCapacity(count)
|
||||||
|
while data.count < count {
|
||||||
|
let chunk = FileHandle.standardInput.readData(ofLength: count - data.count)
|
||||||
|
if chunk.isEmpty { return nil }
|
||||||
|
data.append(chunk)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
class SpeechLiveError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _binary_path() -> Path:
|
||||||
|
digest = hashlib.sha256(_SWIFT_SOURCE.encode()).hexdigest()[:16]
|
||||||
|
return Path.home() / "Library" / "Application Support" / "reyna-cli" / "speech" / f"live-{digest}"
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_binary() -> Path:
|
||||||
|
cached = _binary_path()
|
||||||
|
if cached.exists() and os.access(cached, os.X_OK):
|
||||||
|
return cached
|
||||||
|
swiftc = shutil.which("swiftc") or "/usr/bin/swiftc"
|
||||||
|
if not Path(swiftc).exists():
|
||||||
|
raise SpeechLiveError("swiftc is required for live SpeechTranscriber")
|
||||||
|
cached.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with tempfile.TemporaryDirectory(prefix="reyna-live-build-") as tmp:
|
||||||
|
source = Path(tmp) / "Live.swift"
|
||||||
|
binary = Path(tmp) / "live-transcriber"
|
||||||
|
source.write_text(_SWIFT_SOURCE, encoding="utf-8")
|
||||||
|
result = subprocess.run([swiftc, "-O", "-parse-as-library", str(source), "-o", str(binary), "-framework", "AVFoundation", "-framework", "Speech"], capture_output=True, text=True, timeout=120, check=False)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise SpeechLiveError(f"swiftc failed: {(result.stderr or result.stdout).strip()[:2000]}")
|
||||||
|
shutil.copyfile(binary, cached)
|
||||||
|
cached.chmod(0o700)
|
||||||
|
return cached
|
||||||
|
|
||||||
|
|
||||||
|
class SpeechLiveSession:
|
||||||
|
"""One persistent warmed SpeechTranscriber process, safe for sequential chunks."""
|
||||||
|
def __init__(self, locale: str = "en-US") -> None:
|
||||||
|
self.locale = locale
|
||||||
|
self.binary = _ensure_binary()
|
||||||
|
self.process: Optional[subprocess.Popen[str]] = None
|
||||||
|
self.events: queue.Queue[Dict[str, Any]] = queue.Queue()
|
||||||
|
self.reader: Optional[threading.Thread] = None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if self.process and self.process.poll() is None:
|
||||||
|
return
|
||||||
|
self.process = subprocess.Popen([str(self.binary), self.locale], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=False)
|
||||||
|
assert self.process.stdout is not None
|
||||||
|
def read_lines() -> None:
|
||||||
|
for raw in self.process.stdout:
|
||||||
|
try:
|
||||||
|
value = json.loads(raw.decode("utf-8"))
|
||||||
|
if isinstance(value, dict):
|
||||||
|
self.events.put(value)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
self.reader = threading.Thread(target=read_lines, daemon=True)
|
||||||
|
self.reader.start()
|
||||||
|
|
||||||
|
def transcribe_chunk(self, wav_bytes: bytes, timeout: float = 10.0) -> Dict[str, Any]:
|
||||||
|
if not wav_bytes:
|
||||||
|
raise SpeechLiveError("audio chunk is empty")
|
||||||
|
with self._lock:
|
||||||
|
self.start()
|
||||||
|
assert self.process is not None and self.process.stdin is not None
|
||||||
|
try:
|
||||||
|
self.process.stdin.write(struct.pack(">I", len(wav_bytes)) + wav_bytes)
|
||||||
|
self.process.stdin.flush()
|
||||||
|
except Exception as exc:
|
||||||
|
raise SpeechLiveError(f"live SpeechTranscriber input failed: {exc}") from exc
|
||||||
|
try:
|
||||||
|
event = self.events.get(timeout=timeout)
|
||||||
|
except queue.Empty as exc:
|
||||||
|
raise SpeechLiveError("live SpeechTranscriber timed out") from exc
|
||||||
|
if not event.get("ok", False):
|
||||||
|
raise SpeechLiveError(str(event.get("error") or "live SpeechTranscriber failed"))
|
||||||
|
return {**event, "source": "reyna_cli_direct", "locale": self.locale}
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
process, self.process = self.process, None
|
||||||
|
if not process:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if process.stdin:
|
||||||
|
process.stdin.write(struct.pack(">I", 0)); process.stdin.flush(); process.stdin.close()
|
||||||
|
process.wait(timeout=3)
|
||||||
|
except Exception:
|
||||||
|
process.kill()
|
||||||
|
|
||||||
|
def __enter__(self) -> "SpeechLiveSession":
|
||||||
|
self.start(); return self
|
||||||
|
|
||||||
|
def __exit__(self, *_: Any) -> None:
|
||||||
|
self.close()
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""Unified local voice generation with explicit, non-interchangeable engines."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import wave
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from reyna_cli.local_services_direct import KokoroDirectClient
|
||||||
|
from reyna_cli.media_execution import generate_kokoro
|
||||||
|
from reyna_cli.qwen_tts_direct import Qwen3TTSDirectClient
|
||||||
|
|
||||||
|
POCKET_SAMPLE_RATE = 24_000
|
||||||
|
DEFAULT_POCKET_VOICE_STATE = Path.home() / "Downloads" / "jv_pocket.pt"
|
||||||
|
|
||||||
|
|
||||||
|
class PocketTTSDirectClient:
|
||||||
|
"""Kyutai Pocket TTS with the independently trained JV voice state."""
|
||||||
|
|
||||||
|
def __init__(self, *, voice_state: Optional[Path] = None) -> None:
|
||||||
|
self.voice_state = Path(
|
||||||
|
voice_state
|
||||||
|
or os.environ.get("REYNA_POCKET_TTS_VOICE_STATE")
|
||||||
|
or DEFAULT_POCKET_VOICE_STATE
|
||||||
|
).expanduser()
|
||||||
|
|
||||||
|
def config_status(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"engine": "pocket",
|
||||||
|
"runtime": "Kyutai Pocket TTS",
|
||||||
|
"voice": "jv_pocket",
|
||||||
|
"voice_state": str(self.voice_state),
|
||||||
|
"voice_state_exists": self.voice_state.is_file(),
|
||||||
|
"voice_state_bytes": self.voice_state.stat().st_size if self.voice_state.is_file() else None,
|
||||||
|
"package_available": importlib.util.find_spec("pocket_tts") is not None,
|
||||||
|
"sample_rate": POCKET_SAMPLE_RATE,
|
||||||
|
"source": "direct",
|
||||||
|
"offline": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
def validate_generate(self, text: str) -> Dict[str, Any]:
|
||||||
|
if not text or not text.strip():
|
||||||
|
raise ValueError("text required")
|
||||||
|
if not self.voice_state.is_file():
|
||||||
|
raise FileNotFoundError(f"Pocket voice state not found: {self.voice_state}")
|
||||||
|
if importlib.util.find_spec("pocket_tts") is None:
|
||||||
|
raise RuntimeError("Pocket TTS runtime is not installed; install pocket-tts before generation")
|
||||||
|
return {"text": text.strip()[:8000], "engine": "pocket", "voice": "jv_pocket", "voice_state": str(self.voice_state)}
|
||||||
|
|
||||||
|
def generate(self, text: str, output: Path) -> Dict[str, Any]:
|
||||||
|
self.validate_generate(text)
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
from pocket_tts import TTSModel
|
||||||
|
|
||||||
|
destination = Path(output).expanduser()
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
model = TTSModel.load_model(temp=0.5, lsd_decode_steps=2)
|
||||||
|
state = torch.load(self.voice_state, map_location="cpu", weights_only=True)
|
||||||
|
audio = model.generate_audio(state, text.strip()[:8000])
|
||||||
|
samples = audio.detach().cpu().numpy() if hasattr(audio, "detach") else np.asarray(audio)
|
||||||
|
pcm = (np.clip(samples, -1.0, 1.0) * 32767).astype("<i2")
|
||||||
|
with wave.open(str(destination), "wb") as wav:
|
||||||
|
wav.setnchannels(1)
|
||||||
|
wav.setsampwidth(2)
|
||||||
|
wav.setframerate(POCKET_SAMPLE_RATE)
|
||||||
|
wav.writeframes(pcm.tobytes())
|
||||||
|
if not destination.is_file() or destination.stat().st_size <= 44:
|
||||||
|
raise RuntimeError("Pocket TTS completed without creating audio")
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"engine": "pocket",
|
||||||
|
"voice": "jv_pocket",
|
||||||
|
"voice_state": str(self.voice_state),
|
||||||
|
"filePath": str(destination),
|
||||||
|
"sampleRate": POCKET_SAMPLE_RATE,
|
||||||
|
"bytes": destination.stat().st_size,
|
||||||
|
"source": "direct",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class UnifiedVoiceClient:
|
||||||
|
"""A small dispatcher; engine identifiers are intentionally explicit."""
|
||||||
|
|
||||||
|
ENGINES = ("kokoro", "pocket", "qwen3-tts")
|
||||||
|
|
||||||
|
def __init__(self, *, pocket_voice_state: Optional[Path] = None) -> None:
|
||||||
|
self.pocket = PocketTTSDirectClient(voice_state=pocket_voice_state)
|
||||||
|
|
||||||
|
def config_status(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"source": "direct",
|
||||||
|
"engines": {
|
||||||
|
"kokoro": KokoroDirectClient().config_status(),
|
||||||
|
"pocket": self.pocket.config_status(),
|
||||||
|
"qwen3-tts": Qwen3TTSDirectClient().config_status(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def generate(
|
||||||
|
self,
|
||||||
|
engine: str,
|
||||||
|
text: str,
|
||||||
|
output: Path,
|
||||||
|
*,
|
||||||
|
voice: Optional[str] = None,
|
||||||
|
speed: float = 1.0,
|
||||||
|
lang_code: Optional[str] = None,
|
||||||
|
instruct: Optional[str] = None,
|
||||||
|
temperature: float = 0.3,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
engine = engine.strip().lower()
|
||||||
|
if engine not in self.ENGINES:
|
||||||
|
raise ValueError(f"engine must be one of: {', '.join(self.ENGINES)}")
|
||||||
|
if engine == "kokoro":
|
||||||
|
return generate_kokoro(text, output, voice=voice, speed=speed, lang_code=lang_code)
|
||||||
|
if engine == "pocket":
|
||||||
|
if voice not in (None, "", "jv_pocket"):
|
||||||
|
raise ValueError("Pocket currently supports only the jv_pocket voice state")
|
||||||
|
return self.pocket.generate(text, output)
|
||||||
|
return Qwen3TTSDirectClient(instruct=instruct, temperature=temperature).generate(text, output)
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
from reyna_cli.apple_llm_client import AppleLLMClient, get_apple_llm_session, apple_llm_close, apple_llm_status
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_apple_llm():
|
||||||
|
with patch("subprocess.Popen") as mock_popen, \
|
||||||
|
patch("subprocess.run") as mock_run, \
|
||||||
|
patch("pathlib.Path.write_text"), \
|
||||||
|
patch("shutil.rmtree"):
|
||||||
|
|
||||||
|
# Mock the binary build
|
||||||
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
|
|
||||||
|
# Mock the process
|
||||||
|
mock_proc = MagicMock()
|
||||||
|
mock_proc.poll.return_value = None
|
||||||
|
mock_proc.pid = 1234
|
||||||
|
mock_proc.stdout.readline.side_effect = [
|
||||||
|
'{"id": "0", "ok": true, "text": "Polished text", "ms": 100, "mode": "line"}\\n',
|
||||||
|
'{"id": "1", "ok": true, "text": "Quick reply", "ms": 50, "mode": "quick_reply"}\\n',
|
||||||
|
'{"id": "2", "ok": true, "text": "Chat response", "ms": 200, "mode": "chat"}\\n',
|
||||||
|
'', # EOF
|
||||||
|
]
|
||||||
|
mock_popen.return_value = mock_proc
|
||||||
|
|
||||||
|
client = AppleLLMClient()
|
||||||
|
yield client
|
||||||
|
|
||||||
|
def test_apple_llm_lifecycle(mock_apple_llm):
|
||||||
|
# Test status before start
|
||||||
|
status = apple_llm_status()
|
||||||
|
assert status["active"] is False
|
||||||
|
|
||||||
|
# Start and check status
|
||||||
|
mock_apple_llm.start()
|
||||||
|
status = apple_llm_status()
|
||||||
|
# Note: apple_llm_status uses the global singleton, not the fixture instance.
|
||||||
|
# For the purpose of these tests, we'll mock the global session if needed,
|
||||||
|
# but let's focus on the Client logic first.
|
||||||
|
|
||||||
|
def test_apple_llm_call_success(mock_apple_llm):
|
||||||
|
payload = {"mode": "line", "text": "Hello world"}
|
||||||
|
mock_proc = MagicMock()
|
||||||
|
mock_apple_llm.proc = mock_proc
|
||||||
|
|
||||||
|
def reply_on_write(_: str) -> None:
|
||||||
|
event, result_box = mock_apple_llm.pending["0"]
|
||||||
|
result_box["data"] = {"ok": True, "text": "Polished!"}
|
||||||
|
event.set()
|
||||||
|
|
||||||
|
mock_proc.stdin.write.side_effect = reply_on_write
|
||||||
|
with patch.object(mock_apple_llm, "start"):
|
||||||
|
res = mock_apple_llm.call(payload)
|
||||||
|
|
||||||
|
assert res["ok"] is True
|
||||||
|
assert res["text"] == "Polished!"
|
||||||
|
|
||||||
|
def test_apple_llm_timeout(mock_apple_llm):
|
||||||
|
with patch("threading.Event.wait", return_value=False):
|
||||||
|
res = mock_apple_llm.call({"mode": "line", "text": "test"}, timeout=0.1)
|
||||||
|
assert res["ok"] is False
|
||||||
|
assert "timeout" in res["error"]
|
||||||
|
|
||||||
|
def test_apple_llm_check_mock(mock_apple_llm):
|
||||||
|
with patch("subprocess.run") as mock_run:
|
||||||
|
mock_run.return_value = MagicMock(
|
||||||
|
stdout='{"ok": true, "available": true, "ping": "ok"}',
|
||||||
|
returncode=0
|
||||||
|
)
|
||||||
|
res = mock_apple_llm.check()
|
||||||
|
assert res["ok"] is True
|
||||||
|
assert res["available"] is True
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from reyna_cli.cli import app
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
|
||||||
|
def test_media_execution_local_tts_delegates(monkeypatch, tmp_path):
|
||||||
|
from reyna_cli import media_execution
|
||||||
|
|
||||||
|
target = tmp_path / "speech.wav"
|
||||||
|
|
||||||
|
def fake_synthesize(text, output, **kwargs):
|
||||||
|
output.write_bytes(b"RIFFfake")
|
||||||
|
return output
|
||||||
|
|
||||||
|
monkeypatch.setattr(media_execution, "synthesize_wav", fake_synthesize)
|
||||||
|
result = media_execution.generate_local_tts("hello", target, voice="Alex")
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert result["filePath"] == str(target)
|
||||||
|
assert target.read_bytes() == b"RIFFfake"
|
||||||
|
|
||||||
|
|
||||||
|
def test_media_execution_kokoro_writes_returned_file(monkeypatch, tmp_path):
|
||||||
|
from reyna_cli import media_execution
|
||||||
|
|
||||||
|
source = tmp_path / "source.wav"
|
||||||
|
source.write_bytes(b"wav")
|
||||||
|
target = tmp_path / "target.wav"
|
||||||
|
monkeypatch.setattr(media_execution, "_json_request", lambda *args, **kwargs: {"filePath": str(source), "voice": "af_heart"})
|
||||||
|
result = media_execution.generate_kokoro("hello", target, url="http://127.0.0.1:7332")
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert target.read_bytes() == b"wav"
|
||||||
|
|
||||||
|
|
||||||
|
def test_media_execution_gemini_requires_key(monkeypatch, tmp_path):
|
||||||
|
from reyna_cli import media_execution
|
||||||
|
|
||||||
|
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
|
||||||
|
try:
|
||||||
|
media_execution.generate_gemini_image("a tree", tmp_path / "tree.png")
|
||||||
|
except RuntimeError as exc:
|
||||||
|
assert "GEMINI_API_KEY" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError("missing Gemini key must fail before network")
|
||||||
|
|
||||||
|
|
||||||
|
def test_media_execution_gemini_decodes_image(monkeypatch, tmp_path):
|
||||||
|
from reyna_cli import media_execution
|
||||||
|
|
||||||
|
monkeypatch.setenv("GEMINI_API_KEY", "test-only")
|
||||||
|
monkeypatch.setattr(media_execution, "_json_request", lambda *args, **kwargs: {"id": "i1", "output_image": {"data": "aGVsbG8=", "mime_type": "image/png"}})
|
||||||
|
target = tmp_path / "tree.png"
|
||||||
|
result = media_execution.generate_gemini_image("a tree", target)
|
||||||
|
assert result["path"] == str(target)
|
||||||
|
assert target.read_bytes() == b"hello"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_exposes_execution_commands():
|
||||||
|
for args in [
|
||||||
|
["local-services", "speech", "--help"],
|
||||||
|
["local-services", "kokoro", "--help"],
|
||||||
|
["local-services", "voicebox", "--help"],
|
||||||
|
["local-services", "image", "--help"],
|
||||||
|
]:
|
||||||
|
result = runner.invoke(app, args)
|
||||||
|
assert result.exit_code == 0, result.stdout
|
||||||
|
assert "generate" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_image_rejects_unknown_engine():
|
||||||
|
result = runner.invoke(app, ["local-services", "image", "generate", "tree", "--engine", "unknown", "--json"])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "codex or gemini" in result.stdout
|
||||||
|
assert json.loads(result.stdout)["ok"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_speech_file_execution_uses_cached_binary(monkeypatch, tmp_path):
|
||||||
|
from reyna_cli import speech_execution
|
||||||
|
|
||||||
|
audio = tmp_path / "sample.wav"
|
||||||
|
audio.write_bytes(b"wav")
|
||||||
|
binary = tmp_path / "transcriber"
|
||||||
|
binary.write_bytes(b"binary")
|
||||||
|
|
||||||
|
monkeypatch.setattr(speech_execution, "_ensure_binary", lambda: binary)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
speech_execution.subprocess,
|
||||||
|
"run",
|
||||||
|
lambda *args, **kwargs: type("Result", (), {"returncode": 0, "stdout": '{"ok":true,"transcript":"hello"}', "stderr": ""})(),
|
||||||
|
)
|
||||||
|
result = speech_execution.transcribe_file(audio, locale="en-US")
|
||||||
|
assert result["transcript"] == "hello"
|
||||||
|
assert result["source"] == "reyna_cli_direct"
|
||||||
|
|
||||||
|
|
||||||
|
def test_speech_file_execution_rejects_missing_audio(tmp_path):
|
||||||
|
from reyna_cli.speech_execution import SpeechExecutionError, transcribe_file
|
||||||
|
|
||||||
|
try:
|
||||||
|
transcribe_file(tmp_path / "missing.wav")
|
||||||
|
except SpeechExecutionError as exc:
|
||||||
|
assert "not found" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError("missing audio must fail before compilation")
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_session_frames_audio_and_reads_event(monkeypatch, tmp_path):
|
||||||
|
from reyna_cli import speech_live
|
||||||
|
|
||||||
|
class FakeStdin:
|
||||||
|
def __init__(self):
|
||||||
|
self.data = bytearray()
|
||||||
|
def write(self, value):
|
||||||
|
self.data.extend(value)
|
||||||
|
def flush(self):
|
||||||
|
pass
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class FakeProcess:
|
||||||
|
def __init__(self):
|
||||||
|
self.stdin = FakeStdin()
|
||||||
|
self.stdout = []
|
||||||
|
def poll(self):
|
||||||
|
return None
|
||||||
|
def wait(self, timeout=None):
|
||||||
|
return 0
|
||||||
|
def kill(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
fake = FakeProcess()
|
||||||
|
monkeypatch.setattr(speech_live, "_ensure_binary", lambda: tmp_path / "live")
|
||||||
|
monkeypatch.setattr(speech_live.subprocess, "Popen", lambda *args, **kwargs: fake)
|
||||||
|
session = speech_live.SpeechLiveSession("en-US")
|
||||||
|
session.events.put({"ok": True, "event": "final", "text": "hello"})
|
||||||
|
result = session.transcribe_chunk(b"wav")
|
||||||
|
assert result["text"] == "hello"
|
||||||
|
assert bytes(fake.stdin.data[:4]) == b"\x00\x00\x00\x03"
|
||||||
|
session.close()
|
||||||
+58
-251
@@ -1,268 +1,75 @@
|
|||||||
"""Tests for Notes deferred status — no Notes integration may exist.
|
"""Regression tests for direct mutable Apple Notes CLI support.
|
||||||
|
|
||||||
Assert:
|
Notes are deliberately implemented in Python through a fixed JXA script, not
|
||||||
- no `notes.` protocol ops
|
in the stable Swift privacy host. That preserves the signed launcher binary.
|
||||||
- no native_notes wrappers
|
|
||||||
- no CLI Notes authorization/subcommands
|
|
||||||
- AppleEvents usage key forbidden and absent from plist/app policy
|
|
||||||
- no AppKit link/import
|
|
||||||
- docs correctly say deferred + legacy untouched
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import plistlib
|
import json
|
||||||
from pathlib import Path
|
from types import SimpleNamespace
|
||||||
import re
|
|
||||||
|
|
||||||
REPO = Path(__file__).resolve().parents[1]
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from reyna_cli.cli import app
|
||||||
|
from reyna_cli.notes_direct import create_note, list_notes, read_note
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
|
||||||
def test_no_notes_protocol_ops_in_privacy_contract():
|
def _success_runner(expected_payload, result):
|
||||||
from reyna_cli.privacy_contract import ALLOWED_OPERATIONS, _COMMAND_TO_OPERATION
|
def run(argv, **kwargs):
|
||||||
|
assert argv[:5] == ["/usr/bin/osascript", "-l", "JavaScript", "-e", argv[4]]
|
||||||
|
assert argv[5] == "--"
|
||||||
|
assert json.loads(argv[6]) == expected_payload
|
||||||
|
return SimpleNamespace(returncode=0, stdout=json.dumps(result), stderr="")
|
||||||
|
|
||||||
# No operation may start with notes.
|
return run
|
||||||
for op in ALLOWED_OPERATIONS.keys():
|
|
||||||
assert not op.startswith("notes."), f"forbidden notes op {op} still present — Notes is deferred"
|
|
||||||
|
|
||||||
# Explicit strings must not appear
|
|
||||||
forbidden_ops = {"notes.list", "notes.read", "notes.create", "notes.request_access"}
|
|
||||||
for fo in forbidden_ops:
|
|
||||||
assert fo not in ALLOWED_OPERATIONS, f"forbidden {fo} present"
|
|
||||||
|
|
||||||
for cmd, op in _COMMAND_TO_OPERATION.items():
|
|
||||||
assert not op.startswith("notes."), f"command {cmd} maps to forbidden notes op {op}"
|
|
||||||
assert "notes" not in cmd.lower() or "notes" in cmd.lower() and False is False # allow key detection via op only
|
|
||||||
# also forbid notes_* commands mapping
|
|
||||||
assert not cmd.startswith("notes_"), f"forbidden notes command {cmd}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_native_notes_wrappers():
|
def test_list_notes_uses_fixed_jxa_and_json_argument():
|
||||||
src = (REPO / "src" / "reyna_cli" / "privacy_host.py").read_text()
|
notes = list_notes(
|
||||||
assert "native_notes" not in src, "native_notes wrappers must be removed — Notes deferred"
|
query="family",
|
||||||
assert "NotesProvider" not in src
|
folder="Adolfo",
|
||||||
assert "NotesAuthorization" not in src
|
include_preview=True,
|
||||||
# generic notes field as parameter name for calendar/reminders is allowed,
|
limit=7,
|
||||||
# but operation names notes.list etc are forbidden — already checked by string search above
|
runner=_success_runner(
|
||||||
# Ensure no wrapper defs remain
|
{"query": "family", "folder": "Adolfo", "includePreview": True, "limit": 7},
|
||||||
for name in ["native_notes_list", "native_notes_read", "native_notes_create", "native_notes_request_access"]:
|
[{"id": "n1", "title": "Family", "folder": "Adolfo"}],
|
||||||
assert name not in src
|
),
|
||||||
|
)
|
||||||
|
assert notes == [{"id": "n1", "title": "Family", "folder": "Adolfo"}]
|
||||||
|
|
||||||
|
|
||||||
def test_no_cli_notes_subcommands():
|
def test_read_and_create_notes_use_json_arguments_without_script_interpolation():
|
||||||
cli_src = (REPO / "src" / "reyna_cli" / "cli.py").read_text()
|
note = read_note(
|
||||||
# macmini notes subcommands must be gone
|
"x-coredata://note-1",
|
||||||
assert "macmini_notes" not in cli_src, "macmini_notes commands must be removed"
|
runner=_success_runner(
|
||||||
# CLI must not register a notes typer under macmini
|
{"id": "x-coredata://note-1"},
|
||||||
# Check that macmini help mentions notes is gone — we test via source: no notes app registration
|
{"id": "x-coredata://note-1", "title": "Existing", "plaintext": "body"},
|
||||||
# Allow word 'notes' as parameter name (calendar notes, reminder notes) — but not as subcommand registration
|
),
|
||||||
# So forbid 'notes' app creation for macmini
|
)
|
||||||
# Look for macmini_notes typed list/read/create defs
|
assert note["title"] == "Existing"
|
||||||
assert "def macmini_notes" not in cli_src
|
|
||||||
# Privacy-host notes-authorize must be gone
|
created = create_note(
|
||||||
assert "notes-authorize" not in cli_src
|
"Test <title>",
|
||||||
assert "notes_authorize" not in cli_src
|
"One & two\nthree",
|
||||||
# native_notes usage in cli must be gone
|
folder="Adolfo",
|
||||||
assert "native_notes" not in cli_src
|
runner=_success_runner(
|
||||||
# Ensure NotesProvider strings absent
|
{"title": "Test <title>", "body": "One & two\nthree", "folder": "Adolfo"},
|
||||||
assert "NotesProvider" not in cli_src
|
{"id": "new-1", "title": "Test <title>", "folder": "Adolfo"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert created["id"] == "new-1"
|
||||||
|
|
||||||
|
|
||||||
def test_appleevents_usage_forbidden():
|
def test_notes_cli_is_available_at_top_level_and_macmini_alias(monkeypatch):
|
||||||
from reyna_cli import app_bundle as ab
|
monkeypatch.setattr("reyna_cli.cli.list_notes", lambda **_: [{"id": "n1", "title": "Test"}])
|
||||||
|
|
||||||
d = ab.build_app_bundle_info_plist_dict()
|
top_level = runner.invoke(app, ["notes", "list", "--json"])
|
||||||
assert "NSAppleEventsUsageDescription" not in d, "AppleEvents usage must be forbidden — Notes deferred"
|
assert top_level.exit_code == 0
|
||||||
# Also check Xcode source plist
|
assert json.loads(top_level.stdout)["notes"][0]["id"] == "n1"
|
||||||
xcode_plist = REPO / "native" / "ReynaCLIHost" / "ReynaCLIHost" / "Info.plist"
|
|
||||||
assert xcode_plist.exists()
|
|
||||||
with open(xcode_plist, "rb") as f:
|
|
||||||
xd = plistlib.load(f)
|
|
||||||
assert "NSAppleEventsUsageDescription" not in xd, "Xcode Info.plist must not contain AppleEvents"
|
|
||||||
|
|
||||||
# App bundle policy: forbidden key must be flagged
|
compatibility_alias = runner.invoke(app, ["macmini", "notes", "list", "--json"])
|
||||||
# Validate that forbidden set includes only Calendar, Contacts, Reminders
|
assert compatibility_alias.exit_code == 0
|
||||||
allowed = {
|
assert json.loads(compatibility_alias.stdout)["notes"][0]["title"] == "Test"
|
||||||
"NSCalendarsFullAccessUsageDescription",
|
|
||||||
"NSCalendarsWriteOnlyAccessUsageDescription",
|
|
||||||
"NSCalendarsUsageDescription",
|
|
||||||
"NSContactsUsageDescription",
|
|
||||||
"NSRemindersFullAccessUsageDescription",
|
|
||||||
}
|
|
||||||
for k in d.keys():
|
|
||||||
if k.startswith("NS") and "UsageDescription" in k:
|
|
||||||
assert k in allowed, f"unexpected usage key {k} — only calendar/contacts/reminders allowed (Notes deferred)"
|
|
||||||
|
|
||||||
|
|
||||||
def test_app_bundle_validator_forbids_appleevents():
|
|
||||||
from reyna_cli import app_bundle as ab
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
repo_root = Path(tempfile.mkdtemp()) / "repo"
|
|
||||||
bundle = repo_root / "native" / "ReynaCLIHost" / "dist" / "Reyna CLI.app"
|
|
||||||
contents = bundle / "Contents"
|
|
||||||
macos = contents / "MacOS"
|
|
||||||
macos.mkdir(parents=True)
|
|
||||||
exe = macos / "ReynaCLIHost"
|
|
||||||
exe.write_bytes(b"binary")
|
|
||||||
exe.chmod(0o755)
|
|
||||||
|
|
||||||
class Proc:
|
|
||||||
returncode = 0
|
|
||||||
stdout = ""
|
|
||||||
stderr = "TeamIdentifier=TEAM123\nAuthority=Apple Development: Foo (TEAM123)\n"
|
|
||||||
|
|
||||||
def runner_ok(args, cwd=None, **kwargs):
|
|
||||||
return Proc()
|
|
||||||
|
|
||||||
good = ab.build_app_bundle_info_plist_dict()
|
|
||||||
with open(contents / "Info.plist", "wb") as f:
|
|
||||||
plistlib.dump(good, f)
|
|
||||||
res_ok = ab.validate_app_bundle(repo_root=repo_root, runner=runner_ok)
|
|
||||||
assert res_ok["ok"] is True, f"should accept plist without AppleEvents: {res_ok['errors']}"
|
|
||||||
|
|
||||||
bad = dict(good)
|
|
||||||
bad["NSAppleEventsUsageDescription"] = "Allow automation"
|
|
||||||
with open(contents / "Info.plist", "wb") as f:
|
|
||||||
plistlib.dump(bad, f)
|
|
||||||
res_bad = ab.validate_app_bundle(repo_root=repo_root, runner=runner_ok)
|
|
||||||
assert res_bad["ok"] is False
|
|
||||||
assert any("appleevents" in e.lower() or "unexpected" in e.lower() or "forbidden" in e.lower() for e in res_bad["errors"])
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_appkit_link_or_import():
|
|
||||||
# AppMain and AppEntry must not import AppKit, must not contain AppleEvents
|
|
||||||
app_main = (REPO / "native" / "ReynaCLIHost" / "ReynaCLIHost" / "AppMain.swift").read_text()
|
|
||||||
app_entry = (REPO / "native" / "ReynaCLIHost" / "Sources" / "ReynaCLIHostCore" / "AppEntry.swift").read_text()
|
|
||||||
for txt, label in [(app_main, "AppMain"), (app_entry, "AppEntry")]:
|
|
||||||
assert "AppKit" not in txt, f"{label} must not import AppKit — Notes deferred, headless"
|
|
||||||
assert "NSAppleScript" not in txt
|
|
||||||
assert "NSAppleEvent" not in txt
|
|
||||||
assert "AppleEvents" not in txt
|
|
||||||
|
|
||||||
# Package.swift must not link AppKit
|
|
||||||
pkg = (REPO / "native" / "ReynaCLIHost" / "Package.swift").read_text()
|
|
||||||
assert "AppKit" not in pkg
|
|
||||||
# pbxproj must not contain AppKit, NotesProvider, NotesAuthorization, ForegroundApp, AppleEventsUsageDescription
|
|
||||||
pbx = (REPO / "native" / "ReynaCLIHost" / "ReynaCLIHost.xcodeproj" / "project.pbxproj").read_text()
|
|
||||||
assert "AppKit.framework" not in pbx
|
|
||||||
assert "NotesProvider.swift" not in pbx, "NotesProvider must not be in Xcode project — deferred"
|
|
||||||
assert "NotesAuthorizationProvider.swift" not in pbx
|
|
||||||
assert "ForegroundApp" not in pbx
|
|
||||||
assert "ForegroundAuthorization" not in pbx
|
|
||||||
assert "NSAppleEventsUsageDescription" not in pbx
|
|
||||||
|
|
||||||
# Protocol.swift must not reference notes operations
|
|
||||||
proto = (REPO / "native" / "ReynaCLIHost" / "Sources" / "ReynaCLIHostCore" / "Protocol.swift").read_text()
|
|
||||||
assert "notes.list" not in proto.lower()
|
|
||||||
assert "notes.read" not in proto.lower()
|
|
||||||
assert "notes.create" not in proto.lower()
|
|
||||||
assert "notes.request_access" not in proto.lower()
|
|
||||||
# Generic notes text field for calendar/reminders is allowed, but operation "notes." must not exist
|
|
||||||
# Check for NotesProvider types
|
|
||||||
assert "NotesProvider" not in proto
|
|
||||||
assert "NotesAuthorization" not in proto
|
|
||||||
assert "NoteListItem" not in proto and "NoteDetailItem" not in proto, "Notes data models must be removed"
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_notes_in_swift_sources():
|
|
||||||
core_dir = REPO / "native" / "ReynaCLIHost" / "Sources" / "ReynaCLIHostCore"
|
|
||||||
for p in core_dir.glob("*.swift"):
|
|
||||||
txt = p.read_text()
|
|
||||||
low = txt.lower()
|
|
||||||
# forbid notes. ops
|
|
||||||
assert "notes.list" not in low
|
|
||||||
assert "notes.read" not in low
|
|
||||||
assert "notes.create" not in low
|
|
||||||
assert "notes.request_access" not in low
|
|
||||||
# forbid NSAppleEvents and Notes-type providers
|
|
||||||
assert "nsappleeventsusagedescription" not in low
|
|
||||||
assert "NotesProvider" not in txt
|
|
||||||
assert "NotesAuthorization" not in txt
|
|
||||||
|
|
||||||
|
|
||||||
def test_swift_tests_deleted():
|
|
||||||
tests_dir = REPO / "native" / "ReynaCLIHost" / "Tests" / "ReynaCLIHostTests"
|
|
||||||
forbidden = ["NotesAuthorizationTests.swift", "NotesOperationsTests.swift", "ForegroundAuthorizationTests.swift"]
|
|
||||||
for name in forbidden:
|
|
||||||
assert not (tests_dir / name).exists(), f"{name} must be deleted"
|
|
||||||
|
|
||||||
|
|
||||||
def test_python_notes_tests_deleted():
|
|
||||||
assert not (REPO / "tests" / "test_foreground_notes_ls_authorize.py").exists()
|
|
||||||
assert not (REPO / "tests" / "test_notes_authorization_boundary.py").exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_docs_deferred_legacy_untouched():
|
|
||||||
matrix_path = REPO / "docs" / "remaining-coverage-matrix.md"
|
|
||||||
assert matrix_path.exists()
|
|
||||||
txt = matrix_path.read_text()
|
|
||||||
low = txt.lower()
|
|
||||||
# Must say Notes deferred
|
|
||||||
assert "notes" in low
|
|
||||||
assert "deferred" in low, "docs must say Notes deferred"
|
|
||||||
# Must say legacy untouched
|
|
||||||
assert "legacy" in low
|
|
||||||
assert "untouched" in low, "docs must say legacy untouched"
|
|
||||||
# Must not say Notes belongs inside host as active work — should be in deferred section
|
|
||||||
# Ensure no active plan to add NotesProvider now
|
|
||||||
# Allow legacy mention but not as A TODO — check that if Notes is mentioned as A belongs, it's qualified as deferred
|
|
||||||
# Simplest: ensure the doc contains explicit deferred banner
|
|
||||||
assert "deferred" in txt, "doc must contain deferred word"
|
|
||||||
# Ensure no forbidden old instructions about adding NotesProvider as immediate work without deferred qualifier
|
|
||||||
# The deferred table should list Notes as deferred, not as Done A
|
|
||||||
# We'll just ensure the word deferred appears near Notes line
|
|
||||||
for line in txt.splitlines():
|
|
||||||
if "notes" in line.lower() and ("list/read/create" in line.lower() or "notes.js" in line.lower()):
|
|
||||||
# in that row, must mention deferred or C or out of scope
|
|
||||||
assert "deferred" in line.lower() or "untouched" in txt.lower(), f"Notes row must mention deferred: {line}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_foreground_notes_references():
|
|
||||||
# Search all source/test tree for forbidden patterns — but allow generic param named notes (calendar/reminder text)
|
|
||||||
forbidden_exact = [
|
|
||||||
"NotesProvider",
|
|
||||||
"NotesAuthorization",
|
|
||||||
"ForegroundApp",
|
|
||||||
"foreground-notes",
|
|
||||||
"NSAppleEventsUsageDescription",
|
|
||||||
"notes.request_access",
|
|
||||||
"native_notes",
|
|
||||||
]
|
|
||||||
exclude_dirs = {".venv", "__pycache__", ".git", "build", ".build", "DerivedData", "dist"}
|
|
||||||
for pattern in forbidden_exact:
|
|
||||||
for path in REPO.rglob("*"):
|
|
||||||
if not path.is_file():
|
|
||||||
continue
|
|
||||||
# skip excluded
|
|
||||||
if any(part in exclude_dirs for part in path.parts):
|
|
||||||
continue
|
|
||||||
# skip backup
|
|
||||||
if "backup" in path.parts:
|
|
||||||
continue
|
|
||||||
# Only check relevant extensions
|
|
||||||
if path.suffix not in {".py", ".swift", ".plist", ".md", ".pbxproj", ".toml", ".yaml", ".yml"}:
|
|
||||||
# also check .xcodeproj is dir, pbxproj covered
|
|
||||||
if path.name != "project.pbxproj":
|
|
||||||
continue
|
|
||||||
# Skip this test file itself if pattern is mentioned in test strings — we need to allow self-reference check for patterns inside this file?
|
|
||||||
# For this file we will skip self to avoid false positive on literal search
|
|
||||||
if path.name == "test_notes_deferred.py" or "tests" in path.parts:
|
|
||||||
continue
|
|
||||||
# Skip docs remaining-coverage — allowed to mention pattern but must also say deferred; we already validated
|
|
||||||
# However per task: eliminate only actual Notes integration references (do not remove generic calendar/reminder text fields named notes)
|
|
||||||
# For forbidden patterns search, we strictly forbid integration references in source/test, not docs describing deferred
|
|
||||||
if "docs/" in str(path) and pattern == "NSAppleEventsUsageDescription":
|
|
||||||
continue
|
|
||||||
if path.name == "app_bundle.py" and pattern == "NSAppleEventsUsageDescription":
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
txt = path.read_text(errors="ignore")
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
if pattern in txt:
|
|
||||||
# Allow generic reminder/calendar 'notes' param already excluded by exact list above — so any hit is real integration
|
|
||||||
# But also allow mention in backup
|
|
||||||
if "test_notes_deferred" in str(path):
|
|
||||||
continue
|
|
||||||
raise AssertionError(f"forbidden pattern '{pattern}' found in {path}")
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import pytest
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from reyna_cli.cli import app
|
||||||
|
from reyna_cli.qwen_tts_direct import (
|
||||||
|
JV_SAMPLE_TEXT,
|
||||||
|
MODEL_ID,
|
||||||
|
Qwen3TTSDirectClient,
|
||||||
|
)
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
|
||||||
|
def test_qwen_defaults_match_gitea_voiceagent():
|
||||||
|
client = Qwen3TTSDirectClient()
|
||||||
|
assert client.model_id == MODEL_ID
|
||||||
|
assert client.model_id == "mlx-community/Qwen3-TTS-12Hz-0.6B-Base-bf16"
|
||||||
|
assert client.ref_audio.name == "jv_voice_sample.wav"
|
||||||
|
assert client.ref_text == JV_SAMPLE_TEXT
|
||||||
|
assert client.instruct is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_qwen_config_is_offline_and_reports_jv_asset(tmp_path):
|
||||||
|
ref_audio = tmp_path / "jv_voice_sample.wav"
|
||||||
|
ref_audio.write_bytes(b"not-a-real-wav")
|
||||||
|
client = Qwen3TTSDirectClient(ref_audio=ref_audio)
|
||||||
|
|
||||||
|
status = client.config_status()
|
||||||
|
|
||||||
|
assert status["source"] == "direct"
|
||||||
|
assert status["engine"] == "qwen3-tts"
|
||||||
|
assert status["model_id"] == MODEL_ID
|
||||||
|
assert status["ref_audio"] == str(ref_audio)
|
||||||
|
assert status["ref_audio_exists"] is True
|
||||||
|
assert status["ref_text_configured"] is True
|
||||||
|
assert status["offline"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_qwen_validation_rejects_missing_text_or_reference(tmp_path):
|
||||||
|
client = Qwen3TTSDirectClient(ref_audio=tmp_path / "missing.wav")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="text required"):
|
||||||
|
client.validate_generate("")
|
||||||
|
with pytest.raises(FileNotFoundError, match="reference audio"):
|
||||||
|
client.validate_generate("hello")
|
||||||
|
|
||||||
|
|
||||||
|
def test_qwen_cli_is_registered_alongside_kokoro():
|
||||||
|
result = runner.invoke(app, ["local-services", "qwen3-tts", "--help"])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "generate" in result.output
|
||||||
|
assert "config" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_qwen_cli_exposes_instruct_prompt():
|
||||||
|
result = runner.invoke(app, ["local-services", "qwen3-tts", "generate", "--help"])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "--instruct" in result.output
|
||||||
@@ -1,11 +1,7 @@
|
|||||||
"""Tests for remaining coverage migration — fully consolidated no-Notes CLI.
|
"""Tests for current direct-Notes and native-host coverage boundaries.
|
||||||
|
|
||||||
This file replaces the old Notes-native tests. Validates:
|
The native privacy contract remains Notes-free. Apple Notes is intentionally a
|
||||||
- privacy contract contains only calendar/contacts/reminders/system/speech/apple_llm (no notes)
|
mutable Python CLI route, so it does not require rebuilding the Swift host.
|
||||||
- coverage matrix exists and says Notes deferred + legacy untouched
|
|
||||||
- system info still works via native host
|
|
||||||
- local-services direct wrappers offline safe (no Notes)
|
|
||||||
- docs mention Notes deferred
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -24,11 +20,9 @@ def test_coverage_matrix_exists():
|
|||||||
p = Path(__file__).parents[1] / "docs" / "remaining-coverage-matrix.md"
|
p = Path(__file__).parents[1] / "docs" / "remaining-coverage-matrix.md"
|
||||||
assert p.exists(), f"matrix doc missing at {p}"
|
assert p.exists(), f"matrix doc missing at {p}"
|
||||||
content = p.read_text()
|
content = p.read_text()
|
||||||
# Must mention Notes deferred
|
assert "direct mutable reyna cli" in content.lower()
|
||||||
assert "Notes" in content
|
assert "fixed jxa" in content.lower()
|
||||||
assert "deferred" in content.lower(), "matrix must say Notes deferred"
|
assert "signed bundle" in content.lower()
|
||||||
assert "legacy" in content.lower()
|
|
||||||
assert "untouched" in content.lower()
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Privacy contract — no notes, deferred ────────────────────────────────
|
# ─── Privacy contract — no notes, deferred ────────────────────────────────
|
||||||
@@ -88,12 +82,12 @@ def test_cli_system_info_uses_native(monkeypatch):
|
|||||||
assert payload["ok"] is True
|
assert payload["ok"] is True
|
||||||
|
|
||||||
|
|
||||||
# ─── No Notes CLI ─────────────────────────────────────────────────────────
|
# ─── Direct Notes CLI (outside the native privacy host) ───────────────────
|
||||||
|
|
||||||
def test_cli_macmini_no_notes_subcommand():
|
def test_cli_macmini_notes_compatibility_subcommand():
|
||||||
result = runner.invoke(app, ["macmini", "--help"])
|
result = runner.invoke(app, ["macmini", "--help"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "notes" not in result.stdout.lower(), f"macmini must not list notes — Notes deferred, got: {result.stdout}"
|
assert "notes" in result.stdout.lower()
|
||||||
|
|
||||||
|
|
||||||
def test_cli_privacy_host_no_notes_authorize():
|
def test_cli_privacy_host_no_notes_authorize():
|
||||||
|
|||||||
+1
-9
@@ -164,14 +164,6 @@ def test_devices_laptop_has_subcommands():
|
|||||||
assert "battery" in result.stdout
|
assert "battery" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
def test_devices_arm_has_subcommands():
|
|
||||||
result = runner.invoke(app, ["devices", "arm", "--help"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "state" in result.stdout
|
|
||||||
assert "wave" in result.stdout
|
|
||||||
assert "home" in result.stdout
|
|
||||||
|
|
||||||
|
|
||||||
def test_immich_has_subcommands():
|
def test_immich_has_subcommands():
|
||||||
result = runner.invoke(app, ["immich", "--help"])
|
result = runner.invoke(app, ["immich", "--help"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
@@ -217,7 +209,7 @@ def test_macmini_has_subcommands():
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "calendar" in result.stdout
|
assert "calendar" in result.stdout
|
||||||
assert "contacts" in result.stdout
|
assert "contacts" in result.stdout
|
||||||
assert "notes" not in result.stdout, "Notes deferred — macmini must not list notes"
|
assert "notes" in result.stdout
|
||||||
assert "reminders" in result.stdout
|
assert "reminders" in result.stdout
|
||||||
assert "deco" in result.stdout
|
assert "deco" in result.stdout
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from reyna_cli.cli import app
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_signed_python_invokes_bundle_with_python_and_forwards_args(tmp_path):
|
||||||
|
from reyna_cli.signed_launcher import run_signed_python
|
||||||
|
|
||||||
|
executable = tmp_path / "ReynaCLIHost"
|
||||||
|
executable.write_text("binary")
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_run(args, **kwargs):
|
||||||
|
calls.append((args, kwargs))
|
||||||
|
return SimpleNamespace(returncode=17)
|
||||||
|
|
||||||
|
result = run_signed_python(["local-services", "speech", "generate", "hello"], executable=executable, runner=fake_run)
|
||||||
|
|
||||||
|
assert result == 17
|
||||||
|
assert calls == [(
|
||||||
|
[str(executable), "--python", "local-services", "speech", "generate", "hello"],
|
||||||
|
{"check": False},
|
||||||
|
)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_signed_python_rejects_missing_bundle_executable(tmp_path):
|
||||||
|
from reyna_cli.signed_launcher import SignedLauncherError, run_signed_python
|
||||||
|
|
||||||
|
try:
|
||||||
|
run_signed_python(["doctor"], executable=tmp_path / "missing")
|
||||||
|
except SignedLauncherError as exc:
|
||||||
|
assert "signed Reyna CLI app executable" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError("missing signed executable must fail")
|
||||||
|
|
||||||
|
|
||||||
|
def test_signed_command_forwards_unknown_arguments(monkeypatch):
|
||||||
|
from reyna_cli import signed_launcher
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_run(args):
|
||||||
|
captured["args"] = args
|
||||||
|
return 0
|
||||||
|
|
||||||
|
monkeypatch.setattr(signed_launcher, "run_signed_python", fake_run)
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["signed", "local-services", "speech", "generate", "hello", "--voice", "Alex"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.stdout
|
||||||
|
assert captured["args"] == ["local-services", "speech", "generate", "hello", "--voice", "Alex"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_signed_command_reports_missing_signed_bundle(monkeypatch):
|
||||||
|
from reyna_cli import signed_launcher
|
||||||
|
|
||||||
|
def fake_run(args):
|
||||||
|
raise signed_launcher.SignedLauncherError("signed Reyna CLI app executable is missing")
|
||||||
|
|
||||||
|
monkeypatch.setattr(signed_launcher, "run_signed_python", fake_run)
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["signed", "doctor"])
|
||||||
|
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "signed Reyna CLI app executable is missing" in result.stdout
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from reyna_cli.cli import app
|
||||||
|
from reyna_cli.voice_direct import PocketTTSDirectClient, UnifiedVoiceClient
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
|
||||||
|
def test_unified_voice_config_keeps_three_named_engines_separate(tmp_path):
|
||||||
|
status = UnifiedVoiceClient(pocket_voice_state=tmp_path / "jv_pocket.pt").config_status()
|
||||||
|
|
||||||
|
assert set(status["engines"]) == {"kokoro", "pocket", "qwen3-tts"}
|
||||||
|
assert status["engines"]["pocket"]["voice_state"] == str(tmp_path / "jv_pocket.pt")
|
||||||
|
assert status["engines"]["qwen3-tts"]["model_id"] == "mlx-community/Qwen3-TTS-12Hz-0.6B-Base-bf16"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pocket_validation_requires_exact_custom_state(tmp_path):
|
||||||
|
client = PocketTTSDirectClient(voice_state=tmp_path / "missing.pt")
|
||||||
|
|
||||||
|
try:
|
||||||
|
client.validate_generate("hello")
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
assert "Pocket voice state" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError("missing Pocket state must not silently fall back")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unified_voice_cli_exposes_explicit_engine_selection():
|
||||||
|
result = runner.invoke(app, ["local-services", "voice", "generate", "--help"])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "--engine" in result.output
|
||||||
|
assert "kokoro" in result.output
|
||||||
|
assert "pocket" in result.output
|
||||||
|
assert "qwen3-tts" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_unified_voice_config_cli_is_offline(monkeypatch):
|
||||||
|
result = runner.invoke(app, ["local-services", "voice", "config", "--json"])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert set(json.loads(result.stdout)["result"]["engines"]) == {"kokoro", "pocket", "qwen3-tts"}
|
||||||
Reference in New Issue
Block a user