SPB Git

spb/zyquo-local Public MIT

Native macOS AI chat that runs LLMs 100% locally on Apple Silicon with MLX — no cloud, no API keys.

Swift 97.2% Shell 1.8% Makefile 1%

phase7: verification harness (--verify); phase8 prep: release.sh + entitlements; README

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
simon-pierre boucher committed 12 days ago (Jul 30, 2026) parent 426cec9

Showing 5 changed files with +456 and −0

added README.md +62 −0
@@ -0,0 +1,62 @@
1 +<!--
2 + README.md
3 + Zyquo Local
4 +
5 + Author: Simon-Pierre Boucher
6 + Mail: contact@spboucher.ai
7 +-->
8 +
9 +# Zyquo Local
10 +
11 +**Zyquo Local** is a native macOS AI chat client that runs large language
12 +models **100 % locally on Apple Silicon** with [MLX](https://github.com/ml-explore/mlx-swift).
13 +No API keys, no network calls for inference, no data ever leaving the Mac.
14 +It is the on-device sibling of Zyquo Cloud, sharing the same design DNA — an
15 +emerald-graphite "on-device" identity.
16 +
17 +Browse Hugging Face **inside the app**, download MLX models with one click
18 +(resumable, pausable), and chat with them: token streaming, multi-turn
19 +context management, per-response stats (tok/s, time-to-first-token),
20 +collapsible reasoning for `<think>` models, prompt library, personas,
21 +Quick Chat (⌥Space), two-model Compare mode, Markdown/PDF export.
22 +
23 +- Platform: **Apple Silicon only** (M1+), macOS 14+
24 +- Bundle: `Zyquo Local.app` (`com.zyquo.local`), arm64
25 +- Data: `~/Library/Application Support/ZyquoLocal/` (models in `Models/`)
26 +
27 +## Building (no Xcode IDE)
28 +
29 +The project is plain SwiftPM — no `.xcodeproj`, the Xcode IDE is never used.
30 +Two prerequisites beyond Command Line Tools:
31 +
32 +1. **Apple Metal Toolchain** on `PATH` (MLX compiles GPU kernels at build
33 + time). This machine keeps it at `~/Developer/Metal.xctoolchain`.
34 +2. **SDK pin**: `SDKROOT``MacOSX26.5.sdk` (SwiftUI macro plugins are
35 + Xcode-only in the 27.x CLT SDKs).
36 +
37 +The Makefile handles both:
38 +
39 +```sh
40 +make build # debug build
41 +make dev # debug bundle (ad-hoc signed) + launch
42 +make poc MODEL=<dir> PROMPT="…" # CLI inference proof-of-concept
43 +make icon # regenerate AppIcon.icns from the SVG source
44 +make release # Developer ID signed + notarized + stapled app & DMG
45 +```
46 +
47 +Full recipe and the Phase 0 findings: [`docs/BUILD.md`](docs/BUILD.md).
48 +
49 +## Verification
50 +
51 +`ZyquoLocal --verify` downloads a spread of Featured models (tiny → 8B,
52 +multiple architectures), then for each: validates files, loads, runs a
53 +deterministic generation, a multi-turn exchange, a streaming-cancellation
54 +test, unloads and confirms memory release — and dry-verifies the entire
55 +curated catalog against the live Hub. Results: [`docs/VERIFICATION.md`](docs/VERIFICATION.md).
56 +
57 +## Documentation
58 +
59 +- [`docs/PLAN.md`](docs/PLAN.md) — phase-by-phase build log
60 +- [`docs/MLX-RESEARCH.md`](docs/MLX-RESEARCH.md) — MLX Swift stack research
61 +- [`docs/MODELS.md`](docs/MODELS.md) — HF Hub API contract + curated catalog
62 +- [`docs/BUILD.md`](docs/BUILD.md) — the no-Xcode build recipe
modified Sources/ZyquoLocal/App/Main.swift +4 −0
@@ -23,6 +23,10 @@ enum Main {
23 23 await HubPoCRunner.run()
24 24 return
25 25 }
26 + if args.contains("--verify") {
27 + await VerifyRunner.run(arguments: args)
28 + return
29 + }
26 30 ZyquoLocalApp.main()
27 31 }
28 32 }
added Sources/ZyquoLocal/App/VerifyRunner.swift +286 −0
@@ -0,0 +1,286 @@
1 +//
2 +// VerifyRunner.swift
3 +// Zyquo Local
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +
9 +import Foundation
10 +
11 +/// Phase 7 verification harness: `ZyquoLocal --verify [--keep]`.
12 +/// Proves downloading and running real models works end-to-end on this Mac,
13 +/// and dry-verifies the entire Featured catalog against the live Hub.
14 +enum VerifyRunner {
15 + /// Models under test: span architectures and sizes that fit this Mac.
16 + private static let testRepos = [
17 + "mlx-community/Qwen3-0.6B-4bit", // tiny, qwen3
18 + "mlx-community/Llama-3.2-1B-Instruct-4bit", // tiny, llama
19 + "mlx-community/SmolLM3-3B-4bit", // 3B, smollm3
20 + "mlx-community/gemma-3-4b-it-qat-4bit", // 4B, gemma3
21 + "mlx-community/Qwen2.5-Coder-7B-Instruct-4bit", // coding, qwen2
22 + "mlx-community/DeepSeek-R1-0528-Qwen3-8B-4bit", // reasoning distill
23 + ]
24 +
25 + struct RunResult {
26 + var repoID: String
27 + var download = "–"
28 + var load = "–"
29 + var generate = "–"
30 + var multiTurn = "–"
31 + var cancel = "–"
32 + var unload = "–"
33 + var tokensPerSecond: Double?
34 + var ttft: TimeInterval?
35 + var note = ""
36 + }
37 +
38 + @MainActor
39 + static func run(arguments: [String]) async {
40 + let keepAll = arguments.contains("--keep")
41 + var results: [RunResult] = []
42 +
43 + let store = ModelStore()
44 + let hub = HubService(token: nil)
45 + let downloads = DownloadManager(hub: hub, store: store)
46 +
47 + for repoID in testRepos {
48 + var result = RunResult(repoID: repoID)
49 + log("\n═══ \(repoID) ═══")
50 +
51 + // 1 — download (or reuse)
52 + if store.model(for: repoID) == nil {
53 + log("downloading…")
54 + await downloads.download(repoID: repoID)
55 + var done = false
56 + var lastPercent = -1
57 + while !done {
58 + try? await Task.sleep(for: .milliseconds(500))
59 + guard let t = downloads.task(for: repoID) else {
60 + result.note = "download task vanished"
61 + break
62 + }
63 + switch t.state {
64 + case .completed:
65 + done = true
66 + case .failed:
67 + result.note = t.errorDescription ?? "download failed"
68 + done = true
69 + default:
70 + let percent = Int(t.fractionCompleted * 100)
71 + if percent / 10 != lastPercent / 10 {
72 + lastPercent = percent
73 + log(" \(percent)% \(downloads.speeds[repoID].map(formatSpeed) ?? "")")
74 + }
75 + }
76 + }
77 + }
78 + store.rescan()
79 + guard let model = store.model(for: repoID) else {
80 + result.download = "❌"
81 + results.append(result)
82 + log(" download ❌ \(result.note)")
83 + continue
84 + }
85 + guard ModelStore.isValidModelDirectory(model.directory) else {
86 + result.download = "❌"
87 + result.note = "invalid directory after download"
88 + results.append(result)
89 + continue
90 + }
91 + result.download = "✅"
92 + log(" download ✅ (\(formatBytes(model.sizeBytes)), arch=\(model.architecture ?? "?"))")
93 +
94 + // 2 — load
95 + let engine = InferenceEngine()
96 + do {
97 + try await engine.load(model: model)
98 + result.load = "✅"
99 + log(" load ✅")
100 + } catch {
101 + result.load = "❌"
102 + result.note = error.localizedDescription
103 + results.append(result)
104 + log(" load ❌ \(error.localizedDescription)")
105 + continue
106 + }
107 +
108 + // 3 — deterministic generation
109 + do {
110 + let conversation = Conversation(
111 + params: GenerationParams(temperature: 0, maxTokens: 600))
112 + try await engine.startSession(conversation: conversation)
113 + let (text, stats) = try await collect(
114 + engine: engine, prompt: "Reply with exactly: OK",
115 + params: conversation.params)
116 + var parser = ThinkTagParser()
117 + let (visible, _, _) = parser.consume(text)
118 + let cleaned = visible.trimmingCharacters(in: .whitespacesAndNewlines)
119 + if cleaned.isEmpty {
120 + result.generate = "❌"
121 + result.note = "empty output"
122 + } else {
123 + result.generate = "✅"
124 + result.ttft = stats?.timeToFirstToken
125 + log(" generate ✅ “\(String(cleaned.prefix(60)))”")
126 + }
127 + } catch {
128 + result.generate = "❌"
129 + result.note = error.localizedDescription
130 + log(" generate ❌ \(error.localizedDescription)")
131 + }
132 +
133 + // 4 — multi-turn context carry-over
134 + do {
135 + let conversation = Conversation(
136 + params: GenerationParams(temperature: 0, maxTokens: 800))
137 + try await engine.startSession(conversation: conversation)
138 + _ = try await collect(
139 + engine: engine,
140 + prompt: "My favorite color is vermilion. Just say: noted.",
141 + params: conversation.params)
142 + let (answer, stats) = try await collect(
143 + engine: engine, prompt: "What is my favorite color? Answer in one word.",
144 + params: conversation.params)
145 + if answer.localizedCaseInsensitiveContains("vermilion") {
146 + result.multiTurn = "✅"
147 + result.tokensPerSecond = stats?.tokensPerSecond
148 + log(" multi-turn ✅")
149 + } else {
150 + result.multiTurn = "❌"
151 + result.note = "no context carry-over: “\(String(answer.suffix(80)))”"
152 + log(" multi-turn ❌ \(result.note)")
153 + }
154 + if result.tokensPerSecond == nil { result.tokensPerSecond = stats?.tokensPerSecond }
155 + } catch {
156 + result.multiTurn = "❌"
157 + result.note = error.localizedDescription
158 + }
159 +
160 + // 5 — streaming cancellation
161 + do {
162 + let conversation = Conversation(params: GenerationParams(temperature: 0.7))
163 + try await engine.startSession(conversation: conversation)
164 + let stream = try await engine.generate(
165 + prompt: "Write a very long story about the ocean.",
166 + params: conversation.params)
167 + var tokens = 0
168 + let start = Date()
169 + var sawEnd = false
170 + let consumer = Task {
171 + for try await event in stream {
172 + if case .token = event {
173 + tokens += 1
174 + if tokens == 12 { break } // cancels via onTermination
175 + }
176 + }
177 + }
178 + _ = try? await consumer.value
179 + await engine.stopGeneration()
180 + sawEnd = true
181 + let elapsed = Date().timeIntervalSince(start)
182 + if sawEnd && elapsed < 30 {
183 + result.cancel = "✅"
184 + log(" cancel ✅ (stopped after \(tokens) tokens, \(String(format: "%.1f", elapsed))s)")
185 + } else {
186 + result.cancel = "❌"
187 + }
188 + } catch {
189 + result.cancel = "❌"
190 + result.note = error.localizedDescription
191 + }
192 +
193 + // 6 — unload + memory release
194 + let before = MemoryAdvisor.activeMemoryBytes
195 + await engine.unload()
196 + try? await Task.sleep(for: .milliseconds(500))
197 + let after = MemoryAdvisor.activeMemoryBytes
198 + if after < max(200_000_000, before / 4) {
199 + result.unload = "✅"
200 + log(" unload ✅ (\(formatBytes(Int64(before)))\(formatBytes(Int64(after))))")
201 + } else {
202 + result.unload = "❌"
203 + result.note += " memory not released (\(formatBytes(Int64(after))))"
204 + log(" unload ❌ (\(formatBytes(Int64(before)))\(formatBytes(Int64(after))))")
205 + }
206 +
207 + results.append(result)
208 + }
209 +
210 + // Reclaim disk: keep the smallest model for ongoing dev.
211 + if !keepAll {
212 + for repoID in testRepos.dropFirst() where store.model(for: repoID) != nil {
213 + store.delete(repoID: repoID)
214 + log("deleted \(repoID) to reclaim disk")
215 + }
216 + }
217 +
218 + // ── Catalog dry-verification ────────────────────────────────────────
219 + log("\n═══ Featured catalog dry-verification (30 repos) ═══")
220 + var catalogRows: [(String, String, String)] = []
221 + for entry in ModelCatalog.featured {
222 + do {
223 + let (files, total) = try await hub.requiredFiles(of: entry.repoID)
224 + let gb = Double(total) / 1_000_000_000
225 + let deviation = abs(gb - entry.sizeGB) / entry.sizeGB
226 + let sizeOK = deviation < 0.10
227 + catalogRows.append((
228 + entry.repoID,
229 + "✅ \(files.count) files",
230 + sizeOK
231 + ? String(format: "✅ %.2f GB", gb)
232 + : String(format: "⚠️ %.2f GB (catalog says %.2f)", gb, entry.sizeGB)
233 + ))
234 + } catch {
235 + catalogRows.append((entry.repoID, "❌ \(error.localizedDescription)", "–"))
236 + }
237 + }
238 + for row in catalogRows {
239 + log(" \(row.0): \(row.1) · \(row.2)")
240 + }
241 +
242 + // ── Results table ───────────────────────────────────────────────────
243 + var table = """
244 + | Model | Download | Load | Generate | Multi-turn | Cancel | Unload | tok/s | TTFT |
245 + |---|---|---|---|---|---|---|---|---|
246 +
247 + """
248 + for r in results {
249 + table += "| \(shortModelName(r.repoID)) | \(r.download) | \(r.load) | \(r.generate) | \(r.multiTurn) | \(r.cancel) | \(r.unload) | \(r.tokensPerSecond.map { String(format: "%.1f", $0) } ?? "–") | \(r.ttft.map { String(format: "%.2fs", $0) } ?? "–") |\n"
250 + }
251 + log("\n" + table)
252 +
253 + let allGreen = results.allSatisfy {
254 + $0.download == "✅" && $0.load == "✅" && $0.generate == "✅"
255 + && $0.multiTurn == "✅" && $0.cancel == "✅" && $0.unload == "✅"
256 + }
257 + let catalogGreen = catalogRows.allSatisfy { $0.1.hasPrefix("✅") }
258 + log(allGreen && catalogGreen ? "\nVERIFY: ALL GREEN" : "\nVERIFY: FAILURES PRESENT")
259 + for r in results where !r.note.isEmpty {
260 + log(" note[\(shortModelName(r.repoID))]: \(r.note)")
261 + }
262 + exit(allGreen && catalogGreen ? 0 : 1)
263 + }
264 +
265 + /// Collects one full generation, returning the raw text + final stats.
266 + @MainActor
267 + private static func collect(
268 + engine: InferenceEngine, prompt: String, params: GenerationParams
269 + ) async throws -> (String, GenerationStats?) {
270 + var text = ""
271 + var stats: GenerationStats?
272 + let stream = try await engine.generate(prompt: prompt, params: params)
273 + for try await event in stream {
274 + switch event {
275 + case .token(let t): text += t
276 + case .stats(let s): stats = s
277 + case .finished: break
278 + }
279 + }
280 + return (text, stats)
281 + }
282 +
283 + private static func log(_ text: String) {
284 + FileHandle.standardError.write(Data((text + "\n").utf8))
285 + }
286 +}
added Support/entitlements.plist +21 −0
@@ -0,0 +1,21 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<!--
3 + entitlements.plist
4 + Zyquo Local
5 +
6 + Author: Simon-Pierre Boucher
7 + Mail: contact@spboucher.ai
8 +-->
9 +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
10 +<plist version="1.0">
11 +<dict>
12 + <!-- Hardened runtime is enabled at signing time (codesign
13 + - -options runtime). Zyquo Local is not sandboxed (like Zyquo Term);
14 + outbound network to huggingface.co needs no entitlement outside the
15 + sandbox. MLX's runtime Metal kernel compilation happens in the Metal
16 + system service, not via in-process JIT, so no JIT/unsigned-memory
17 + entitlements are required — verified by running the signed build. -->
18 + <key>com.apple.security.cs.allow-jit</key>
19 + <false/>
20 +</dict>
21 +</plist>
added scripts/release.sh +83 −0
@@ -0,0 +1,83 @@
1 +#!/bin/bash
2 +#
3 +# release.sh
4 +# Zyquo Local
5 +#
6 +# Author: Simon-Pierre Boucher
7 +# Mail: contact@spboucher.ai
8 +#
9 +# Developer ID signing, notarization and stapling for Zyquo Local.
10 +# Identity and notarytool profile reused from the Zyquo Term pipeline.
11 +#
12 +# Usage:
13 +# scripts/release.sh # full: build + bundle + sign + notarize + staple + dmg
14 +# scripts/release.sh sign|notarize|dmg # individual steps
15 +
16 +set -euo pipefail
17 +cd "$(dirname "$0")/.."
18 +
19 +export SDKROOT=/Library/Developer/CommandLineTools/SDKs/MacOSX26.5.sdk
20 +export PATH="$HOME/Developer/Metal.xctoolchain/usr/bin:$PATH"
21 +
22 +IDENTITY="Developer ID Application: Simon-Pierre Boucher (3YM54G49SN)"
23 +KEYCHAIN_PROFILE="MacLustr-Notarize"
24 +APP_DIR="dist/Zyquo Local.app"
25 +ZIP_NAME="dist/ZyquoLocal.zip"
26 +DMG_NAME="dist/ZyquoLocal.dmg"
27 +ENTITLEMENTS="Support/entitlements.plist"
28 +
29 +build() {
30 + echo "=== Release build + bundle ==="
31 + make bundle-release
32 +}
33 +
34 +sign() {
35 + [ -d "$APP_DIR" ] || { echo "ERROR: $APP_DIR missing — run 'make bundle-release' first" >&2; exit 1; }
36 + echo "=== Signing (Developer ID, hardened runtime) ==="
37 + # Nested code first: resource bundles (the Cmlx bundle seals the MLX
38 + # metallib — the classic notarization culprit), then the app.
39 + find "$APP_DIR/Contents/Resources" -maxdepth 1 -name '*.bundle' -print0 |
40 + while IFS= read -r -d '' bundle; do
41 + codesign --force --options runtime --timestamp --sign "$IDENTITY" "$bundle"
42 + done
43 + codesign --force --options runtime --timestamp \
44 + --entitlements "$ENTITLEMENTS" \
45 + --sign "$IDENTITY" "$APP_DIR"
46 + codesign --verify --deep --strict --verbose=2 "$APP_DIR"
47 + echo "Signature valid."
48 +}
49 +
50 +notarize() {
51 + echo "=== Notarizing app (profile: $KEYCHAIN_PROFILE) ==="
52 + rm -f "$ZIP_NAME"
53 + ditto -c -k --keepParent "$APP_DIR" "$ZIP_NAME"
54 + xcrun notarytool submit "$ZIP_NAME" --keychain-profile "$KEYCHAIN_PROFILE" --wait
55 + xcrun stapler staple "$APP_DIR"
56 + xcrun stapler validate "$APP_DIR"
57 + spctl -a -vv "$APP_DIR"
58 + echo "App notarized and stapled."
59 +}
60 +
61 +dmg() {
62 + echo "=== Creating signed + notarized DMG ==="
63 + rm -f "$DMG_NAME"
64 + DMG_TEMP="dist/dmg_temp"
65 + rm -rf "$DMG_TEMP"
66 + mkdir -p "$DMG_TEMP"
67 + cp -R "$APP_DIR" "$DMG_TEMP/"
68 + ln -s /Applications "$DMG_TEMP/Applications"
69 + hdiutil create -volname "Zyquo Local" -srcfolder "$DMG_TEMP" -ov -format UDZO "$DMG_NAME"
70 + rm -rf "$DMG_TEMP"
71 + codesign --force --sign "$IDENTITY" --timestamp "$DMG_NAME"
72 + xcrun notarytool submit "$DMG_NAME" --keychain-profile "$KEYCHAIN_PROFILE" --wait
73 + xcrun stapler staple "$DMG_NAME"
74 + echo "DMG ready: $DMG_NAME"
75 +}
76 +
77 +case "${1:-dist}" in
78 + sign) sign ;;
79 + notarize) notarize ;;
80 + dmg) dmg ;;
81 + dist) build; sign; notarize; dmg ;;
82 + *) echo "Usage: $0 {sign|notarize|dmg|dist}" >&2; exit 64 ;;
83 +esac
84