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%

phase3: resumable downloads with progress, live Hub search, model store — gate green

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

Showing 9 changed files with +1,369 and −1

added Sources/ZyquoLocal/App/HubPoCRunner.swift +131 −0
@@ -0,0 +1,131 @@
1 +//
2 +// HubPoCRunner.swift
3 +// Zyquo Local
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +
9 +import Foundation
10 +
11 +/// Phase 3 gate: `ZyquoLocal --hub-poc` proves search → download with live
12 +/// progress → pause mid-flight → resume → validate → delete, end to end,
13 +/// against the live Hugging Face Hub, in an isolated temporary Models root.
14 +enum HubPoCRunner {
15 + private static let testRepo = "mlx-community/Qwen3-0.6B-4bit"
16 +
17 + @MainActor
18 + static func run() async {
19 + let hub = HubService(token: nil)
20 +
21 + // 1 — live search
22 + log("1) Live Hub search: “qwen” in mlx-community, by downloads…")
23 + do {
24 + let results = try await hub.search(query: "qwen", scope: .mlxCommunity, limit: 5)
25 + for r in results {
26 + log(" • \(r.id)\(r.downloads ?? 0) arch=\(r.architecture ?? "?")\(r.isSupportedArchitecture ? "" : " ⚠️ unsupported")")
27 + }
28 + guard !results.isEmpty else { return fail("search returned nothing") }
29 + } catch {
30 + return fail("search: \(error.localizedDescription)")
31 + }
32 +
33 + // Isolated store for the test
34 + let root = FileManager.default.temporaryDirectory
35 + .appendingPathComponent("ZyquoLocalHubPoC-\(UUID().uuidString)")
36 + let store = ModelStore(root: root)
37 + let manager = DownloadManager(hub: hub, store: store)
38 + defer { try? FileManager.default.removeItem(at: root) }
39 +
40 + // 2 — file listing + size
41 + log("2) File listing for \(testRepo)…")
42 + do {
43 + let (files, total) = try await hub.requiredFiles(of: testRepo)
44 + log(" \(files.count) required files, total \(gb(total))")
45 + } catch {
46 + return fail("file listing: \(error.localizedDescription)")
47 + }
48 +
49 + // 3 — download with live progress, pause at ≥25 %
50 + log("3) Downloading with live progress; pausing mid-flight…")
51 + await manager.download(repoID: testRepo)
52 + var paused = false
53 + while !paused {
54 + try? await Task.sleep(for: .milliseconds(200))
55 + guard let t = manager.task(for: testRepo) else { return fail("task vanished") }
56 + if t.state == .failed { return fail("download failed: \(t.errorDescription ?? "?")") }
57 + if t.fractionCompleted >= 0.25 {
58 + manager.pause(repoID: testRepo)
59 + paused = true
60 + let speed = manager.speeds[testRepo].map { "\(gb(Int64($0)))/s" } ?? "n/a"
61 + log(" paused at \(pct(t.fractionCompleted)) (speed was \(speed))")
62 + }
63 + }
64 + try? await Task.sleep(for: .milliseconds(400))
65 +
66 + // 4 — verify partials survive, then resume
67 + guard let pausedTask = manager.task(for: testRepo), pausedTask.state == .paused else {
68 + return fail("expected paused state")
69 + }
70 + let partialBytes = pausedTask.receivedBytes
71 + guard partialBytes > 0 else { return fail("no partial bytes on disk") }
72 + log("4) Resuming from \(gb(partialBytes)) (HTTP Range)…")
73 + manager.resume(repoID: testRepo)
74 +
75 + var completed = false
76 + var lastLogged = -1
77 + while !completed {
78 + try? await Task.sleep(for: .milliseconds(300))
79 + guard let t = manager.task(for: testRepo) else { return fail("task vanished") }
80 + switch t.state {
81 + case .completed:
82 + completed = true
83 + case .failed:
84 + return fail("resume failed: \(t.errorDescription ?? "?")")
85 + default:
86 + let percent = Int(t.fractionCompleted * 100)
87 + if percent / 20 != lastLogged / 20 {
88 + lastLogged = percent
89 + log(" … \(percent)% (\(gb(t.receivedBytes))/\(gb(t.totalBytes)))")
90 + }
91 + }
92 + }
93 + log(" download completed + size-verified ✅")
94 +
95 + // 5 — store validation
96 + log("5) Validating model directory + store scan…")
97 + store.rescan()
98 + guard let model = store.model(for: testRepo) else {
99 + return fail("ModelStore did not recognize the downloaded model")
100 + }
101 + log(" \(model.repoID): \(gb(model.sizeBytes)), arch=\(model.architecture ?? "?"), quant=\(model.quantization ?? "?"), ctx=\(model.contextWindow ?? 0), verdict=\(MemoryAdvisor.verdict(weightsBytes: model.sizeBytes).label)")
102 +
103 + // 6 — delete
104 + log("6) Deleting…")
105 + let reclaimed = store.delete(repoID: testRepo)
106 + guard store.model(for: testRepo) == nil,
107 + !FileManager.default.fileExists(atPath: root.appendingPathComponent(testRepo).path)
108 + else { return fail("delete left files behind") }
109 + log(" reclaimed \(gb(reclaimed)) ✅")
110 +
111 + log("\nHUB POC: ALL STEPS GREEN")
112 + exit(0)
113 + }
114 +
115 + private static func log(_ text: String) {
116 + FileHandle.standardError.write(Data((text + "\n").utf8))
117 + }
118 +
119 + private static func fail(_ text: String) {
120 + log("FAIL: \(text)")
121 + exit(1)
122 + }
123 +
124 + private static func gb(_ bytes: Int64) -> String {
125 + ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)
126 + }
127 +
128 + private static func pct(_ fraction: Double) -> String {
129 + "\(Int(fraction * 100))%"
130 + }
131 +}
modified Sources/ZyquoLocal/App/Main.swift +4 −0
@@ -19,6 +19,10 @@ enum Main {
19 19 await PoCRunner.run(arguments: args)
20 20 return
21 21 }
22 + if args.contains("--hub-poc") {
23 + await HubPoCRunner.run()
24 + return
25 + }
22 26 ZyquoLocalApp.main()
23 27 }
24 28 }
added Sources/ZyquoLocal/Hub/DownloadManager.swift +371 −0
@@ -0,0 +1,371 @@
1 +//
2 +// DownloadManager.swift
3 +// Zyquo Local
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +
9 +import Foundation
10 +import Observation
11 +
12 +/// Queued, resumable model downloads.
13 +///
14 +/// - Files download to `<name>.partial` and are moved into place atomically;
15 +/// a model is valid only when every file is present and size-verified.
16 +/// - Resume uses HTTP Range from the partial file's size — surviving pause,
17 +/// cancel-restart, and app relaunch (manifest + partials persist on disk).
18 +/// - Max 2 concurrent file downloads per model.
19 +@MainActor
20 +@Observable
21 +final class DownloadManager {
22 + private(set) var tasks: [DownloadTask] = []
23 + /// Live transfer speed per repoID, bytes/sec.
24 + private(set) var speeds: [String: Double] = [:]
25 +
26 + var hub: HubService
27 + private let store: ModelStore
28 + private var workers: [String: Task<Void, Never>] = [:]
29 + /// Speed window per repoID: last sample time and cumulative bytes.
30 + private var speedSamples: [String: (time: ContinuousClock.Instant, bytes: Int64)] = [:]
31 +
32 + /// Active + queued downloads (for the sidebar badge).
33 + var activeCount: Int {
34 + tasks.filter { $0.state == .downloading || $0.state == .queued }.count
35 + }
36 +
37 + var overallFraction: Double {
38 + let active = tasks.filter { $0.state == .downloading || $0.state == .paused }
39 + guard !active.isEmpty else { return 0 }
40 + return active.reduce(0.0) { $0 + $1.fractionCompleted } / Double(active.count)
41 + }
42 +
43 + init(hub: HubService, store: ModelStore) {
44 + self.hub = hub
45 + self.store = store
46 + restoreManifest()
47 + }
48 +
49 + enum DownloadError: LocalizedError {
50 + case insufficientDiskSpace(needed: Int64, available: Int64)
51 + case sizeMismatch(file: String)
52 + case interrupted
53 +
54 + var errorDescription: String? {
55 + switch self {
56 + case .insufficientDiskSpace(let needed, let available):
57 + "Not enough disk space: \(ByteCountFormatter.string(fromByteCount: needed, countStyle: .file)) needed, \(ByteCountFormatter.string(fromByteCount: available, countStyle: .file)) available."
58 + case .sizeMismatch(let file):
59 + "Downloaded file \(file) does not match its expected size. The download will retry from scratch for this file."
60 + case .interrupted:
61 + "The download was interrupted. It can be resumed from where it left off."
62 + }
63 + }
64 + }
65 +
66 + // MARK: - Public API
67 +
68 + /// Starts (or restarts) downloading all required files of a repo.
69 + func download(repoID: String) async {
70 + if let existing = task(for: repoID),
71 + existing.state == .downloading || existing.state == .queued
72 + {
73 + return
74 + }
75 + do {
76 + let (files, totalBytes) = try await hub.requiredFiles(of: repoID)
77 + try checkDiskSpace(needed: totalBytes)
78 + let dir = store.directory(for: repoID)
79 + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
80 +
81 + var progress: [DownloadTask.FileProgress] = files.map { file in
82 + let partial = dir.appendingPathComponent(file.path + ".partial")
83 + let final = dir.appendingPathComponent(file.path)
84 + let already: Int64
85 + let done: Bool
86 + if FileManager.default.fileExists(atPath: final.path) {
87 + already = file.size ?? 0
88 + done = true
89 + } else {
90 + already = (try? FileManager.default.attributesOfItem(atPath: partial.path)[.size] as? Int64) ?? 0
91 + done = false
92 + }
93 + return DownloadTask.FileProgress(
94 + path: file.path,
95 + totalBytes: file.size ?? 0,
96 + receivedBytes: already,
97 + sha256: file.lfs?.oid,
98 + completed: done
99 + )
100 + }
101 + progress.sort { $0.totalBytes < $1.totalBytes } // small config files first
102 + upsert(DownloadTask(
103 + repoID: repoID, state: .downloading, files: progress,
104 + errorDescription: nil, startedAt: Date()
105 + ))
106 + startWorker(repoID: repoID)
107 + } catch {
108 + upsert(DownloadTask(
109 + repoID: repoID, state: .failed, files: task(for: repoID)?.files ?? [],
110 + errorDescription: error.localizedDescription, startedAt: Date()
111 + ))
112 + }
113 + }
114 +
115 + func pause(repoID: String) {
116 + workers[repoID]?.cancel()
117 + workers[repoID] = nil
118 + mutate(repoID) { $0.state = .paused }
119 + }
120 +
121 + func resume(repoID: String) {
122 + guard var t = task(for: repoID), t.state == .paused || t.state == .failed else { return }
123 + t.state = .downloading
124 + t.errorDescription = nil
125 + upsert(t)
126 + startWorker(repoID: repoID)
127 + }
128 +
129 + /// Cancels and removes all partial data.
130 + func cancel(repoID: String) {
131 + workers[repoID]?.cancel()
132 + workers[repoID] = nil
133 + let dir = store.directory(for: repoID)
134 + if let t = task(for: repoID) {
135 + for f in t.files where !f.completed {
136 + try? FileManager.default.removeItem(at: dir.appendingPathComponent(f.path + ".partial"))
137 + }
138 + }
139 + // Remove the directory when nothing complete remains in it.
140 + if let contents = try? FileManager.default.contentsOfDirectory(atPath: dir.path), contents.isEmpty {
141 + try? FileManager.default.removeItem(at: dir)
142 + }
143 + tasks.removeAll { $0.repoID == repoID }
144 + speeds[repoID] = nil
145 + speedSamples[repoID] = nil
146 + persistManifest()
147 + }
148 +
149 + func task(for repoID: String) -> DownloadTask? {
150 + tasks.first { $0.repoID == repoID }
151 + }
152 +
153 + /// ETA in seconds based on current speed.
154 + func eta(for repoID: String) -> TimeInterval? {
155 + guard let t = task(for: repoID), let speed = speeds[t.repoID], speed > 1 else { return nil }
156 + return Double(t.totalBytes - t.receivedBytes) / speed
157 + }
158 +
159 + // MARK: - Worker
160 +
161 + private func startWorker(repoID: String) {
162 + workers[repoID]?.cancel()
163 + workers[repoID] = Task { [weak self] in
164 + await self?.runWorker(repoID: repoID)
165 + }
166 + }
167 +
168 + private func runWorker(repoID: String) async {
169 + let dir = store.directory(for: repoID)
170 + guard let snapshot = task(for: repoID) else { return }
171 + let pending = snapshot.files.filter { !$0.completed }
172 +
173 + do {
174 + try await withThrowingTaskGroup(of: Void.self) { group in
175 + var iterator = pending.makeIterator()
176 + var inFlight = 0
177 + func addNext(_ group: inout ThrowingTaskGroup<Void, Error>) {
178 + guard let file = iterator.next() else { return }
179 + inFlight += 1
180 + group.addTask { [weak self] in
181 + try await self?.downloadFile(repoID: repoID, file: file, directory: dir)
182 + }
183 + }
184 + addNext(&group)
185 + addNext(&group) // 2 concurrent file downloads max
186 + while inFlight > 0 {
187 + try await group.next()
188 + inFlight -= 1
189 + addNext(&group)
190 + }
191 + }
192 + mutate(repoID) { $0.state = .verifying }
193 + try verify(repoID: repoID, directory: dir)
194 + mutate(repoID) { $0.state = .completed }
195 + speeds[repoID] = nil
196 + store.rescan()
197 + } catch is CancellationError {
198 + // pause() already set the state; on app quit partials just persist.
199 + } catch {
200 + if Task.isCancelled { return }
201 + mutate(repoID) {
202 + $0.state = .failed
203 + $0.errorDescription = error.localizedDescription
204 + }
205 + speeds[repoID] = nil
206 + }
207 + workers[repoID] = nil
208 + }
209 +
210 + /// Downloads one file with Range-resume into `<path>.partial`, then moves
211 + /// it into place. Retries transient network drops from the partial offset.
212 + /// Throws CancellationError on pause.
213 + private func downloadFile(repoID: String, file: DownloadTask.FileProgress, directory: URL) async throws {
214 + let partial = directory.appendingPathComponent(file.path + ".partial")
215 + let final = directory.appendingPathComponent(file.path)
216 + try FileManager.default.createDirectory(
217 + at: partial.deletingLastPathComponent(), withIntermediateDirectories: true)
218 + if !FileManager.default.fileExists(atPath: partial.path) {
219 + FileManager.default.createFile(atPath: partial.path, contents: nil)
220 + }
221 +
222 + var request = URLRequest(url: HubService.resolveURL(repoID: repoID, path: file.path))
223 + request.setValue("ZyquoLocal/0.1.0", forHTTPHeaderField: "User-Agent")
224 + if let token = hub.token, !token.isEmpty {
225 + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
226 + }
227 +
228 + let path = file.path
229 + let transfer = FileTransfer(partial: partial) { [weak self] received in
230 + Task { @MainActor [weak self] in
231 + self?.updateProgress(repoID: repoID, path: path, received: received)
232 + }
233 + }
234 +
235 + var attempts = 0
236 + while true {
237 + attempts += 1
238 + let offset: Int64 =
239 + (try? FileManager.default.attributesOfItem(atPath: partial.path)[.size] as? Int64) ?? 0
240 + do {
241 + _ = try await transfer.run(request: request, offset: offset)
242 + break
243 + } catch let error as FileTransfer.TransferError {
244 + switch error {
245 + case .badStatus(401), .badStatus(403):
246 + throw HubService.HubError.gatedOrMissing(repoID)
247 + case .badStatus(416):
248 + // Range beyond EOF: complete if the size matches, else restart.
249 + let size = (try? FileManager.default.attributesOfItem(atPath: partial.path)[.size] as? Int64) ?? 0
250 + if size == file.totalBytes { break }
251 + try? FileManager.default.removeItem(at: partial)
252 + FileManager.default.createFile(atPath: partial.path, contents: nil)
253 + if attempts >= 3 { throw DownloadError.sizeMismatch(file: file.path) }
254 + continue
255 + case .badStatus(let code):
256 + throw HubService.HubError.http(code, repoID)
257 + }
258 + } catch is CancellationError {
259 + throw CancellationError()
260 + } catch {
261 + // Transient drop (connection lost, timeout…): retry with backoff
262 + // from whatever the partial already holds.
263 + guard attempts < 4 else { throw error }
264 + try await Task.sleep(for: .seconds(Double(attempts)))
265 + try Task.checkCancellation()
266 + continue
267 + }
268 + }
269 + try finishFile(partial: partial, final: final, repoID: repoID, path: file.path, expected: file.totalBytes)
270 + }
271 +
272 + /// Size check + atomic move into place.
273 + private func finishFile(partial: URL, final: URL, repoID: String, path: String, expected: Int64) throws {
274 + let size = (try? FileManager.default.attributesOfItem(atPath: partial.path)[.size] as? Int64) ?? -1
275 + guard expected == 0 || size == expected else {
276 + try? FileManager.default.removeItem(at: partial)
277 + updateProgress(repoID: repoID, path: path, received: 0)
278 + throw DownloadError.sizeMismatch(file: path)
279 + }
280 + if FileManager.default.fileExists(atPath: final.path) {
281 + try FileManager.default.removeItem(at: final)
282 + }
283 + try FileManager.default.moveItem(at: partial, to: final)
284 + mutate(repoID) { t in
285 + if let i = t.files.firstIndex(where: { $0.path == path }) {
286 + t.files[i].completed = true
287 + t.files[i].receivedBytes = t.files[i].totalBytes
288 + }
289 + }
290 + }
291 +
292 + /// Final integrity pass: every required file present with expected size.
293 + private func verify(repoID: String, directory: URL) throws {
294 + guard let t = task(for: repoID) else { return }
295 + for f in t.files {
296 + let url = directory.appendingPathComponent(f.path)
297 + let size = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int64) ?? -1
298 + guard f.totalBytes == 0 || size == f.totalBytes else {
299 + throw DownloadError.sizeMismatch(file: f.path)
300 + }
301 + }
302 + }
303 +
304 + private func checkDiskSpace(needed: Int64) throws {
305 + let values = try? store.modelsRoot.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
306 + let available = values?.volumeAvailableCapacityForImportantUsage ?? 0
307 + // 5 % headroom so a download never fills the disk completely.
308 + if available < Int64(Double(needed) * 1.05) {
309 + throw DownloadError.insufficientDiskSpace(needed: needed, available: available)
310 + }
311 + }
312 +
313 + // MARK: - State & manifest
314 +
315 + private func upsert(_ t: DownloadTask) {
316 + if let i = tasks.firstIndex(where: { $0.repoID == t.repoID }) {
317 + tasks[i] = t
318 + } else {
319 + tasks.append(t)
320 + }
321 + persistManifest()
322 + }
323 +
324 + private func mutate(_ repoID: String, _ change: (inout DownloadTask) -> Void) {
325 + guard let i = tasks.firstIndex(where: { $0.repoID == repoID }) else { return }
326 + change(&tasks[i])
327 + persistManifest()
328 + }
329 +
330 + private func updateProgress(repoID: String, path: String, received: Int64) {
331 + guard let i = tasks.firstIndex(where: { $0.repoID == repoID }) else { return }
332 + if let j = tasks[i].files.firstIndex(where: { $0.path == path }) {
333 + tasks[i].files[j].receivedBytes = received
334 + }
335 + // Smoothed speed over a sliding window of the whole repo's bytes.
336 + let totalReceived = tasks[i].receivedBytes
337 + let now = ContinuousClock.now
338 + if let sample = speedSamples[repoID] {
339 + let d = sample.time.duration(to: now)
340 + let elapsed = Double(d.components.seconds) + Double(d.components.attoseconds) * 1e-18
341 + if elapsed >= 1.0 {
342 + let instant = Double(totalReceived - sample.bytes) / elapsed
343 + let previous = speeds[repoID] ?? instant
344 + speeds[repoID] = previous * 0.6 + instant * 0.4
345 + speedSamples[repoID] = (now, totalReceived)
346 + }
347 + } else {
348 + speedSamples[repoID] = (now, totalReceived)
349 + }
350 + }
351 +
352 + private var manifestURL: URL {
353 + PersistenceService.appSupportDirectory.appendingPathComponent("downloads.json")
354 + }
355 +
356 + private func persistManifest() {
357 + let persistable = tasks.filter { $0.state != .completed && $0.state != .cancelled }
358 + try? JSONEncoder().encode(persistable).write(to: manifestURL, options: .atomic)
359 + }
360 +
361 + /// Restores paused/interrupted downloads after relaunch.
362 + private func restoreManifest() {
363 + guard let data = try? Data(contentsOf: manifestURL),
364 + var restored = try? JSONDecoder().decode([DownloadTask].self, from: data)
365 + else { return }
366 + for i in restored.indices where restored[i].state == .downloading || restored[i].state == .queued {
367 + restored[i].state = .paused
368 + }
369 + tasks = restored
370 + }
371 +}
\ No newline at end of file
added Sources/ZyquoLocal/Hub/FileTransfer.swift +148 −0
@@ -0,0 +1,148 @@
1 +//
2 +// FileTransfer.swift
3 +// Zyquo Local
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +
9 +import Foundation
10 +
11 +/// One HTTP transfer of one file into a `.partial` on disk, delegate-backed
12 +/// for chunked throughput. Supports Range resume (server 206), transparent
13 +/// full restarts (server 200), cancellation, and strips the Authorization
14 +/// header when redirected off huggingface.co to the signed CDN.
15 +final class FileTransfer: NSObject, URLSessionDataDelegate, @unchecked Sendable {
16 + enum TransferError: LocalizedError {
17 + case badStatus(Int)
18 +
19 + var errorDescription: String? {
20 + switch self {
21 + case .badStatus(let code): "Server returned HTTP \(code)."
22 + }
23 + }
24 + }
25 +
26 + private let partial: URL
27 + /// Cumulative bytes on disk, throttled (~10 Hz).
28 + private let onProgress: @Sendable (Int64) -> Void
29 +
30 + private var handle: FileHandle?
31 + private var received: Int64 = 0
32 + private var status = 0
33 + private var rejectedByStatus = false
34 + private var continuation: CheckedContinuation<Int64, Error>?
35 + private var lastReport = ContinuousClock.now
36 +
37 + init(partial: URL, onProgress: @escaping @Sendable (Int64) -> Void) {
38 + self.partial = partial
39 + self.onProgress = onProgress
40 + }
41 +
42 + /// Runs the transfer starting at `offset` (0 = fresh). Returns final byte
43 + /// count on disk. Throws CancellationError when the task is cancelled.
44 + func run(request: URLRequest, offset: Int64) async throws -> Int64 {
45 + received = offset
46 + status = 0
47 + rejectedByStatus = false
48 +
49 + let config = URLSessionConfiguration.default
50 + config.timeoutIntervalForRequest = 60
51 + config.networkServiceType = .responsiveData
52 + let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
53 + defer { session.finishTasksAndInvalidate() }
54 +
55 + var request = request
56 + if offset > 0 {
57 + request.setValue("bytes=\(offset)-", forHTTPHeaderField: "Range")
58 + }
59 + let task = session.dataTask(with: request)
60 +
61 + return try await withTaskCancellationHandler {
62 + try await withCheckedThrowingContinuation { c in
63 + continuation = c
64 + task.resume()
65 + }
66 + } onCancel: {
67 + task.cancel()
68 + }
69 + }
70 +
71 + // MARK: - URLSessionDataDelegate
72 +
73 + func urlSession(
74 + _ session: URLSession, task: URLSessionTask,
75 + willPerformHTTPRedirection response: HTTPURLResponse,
76 + newRequest request: URLRequest,
77 + completionHandler: @escaping (URLRequest?) -> Void
78 + ) {
79 + var request = request
80 + if request.url?.host != task.originalRequest?.url?.host {
81 + request.setValue(nil, forHTTPHeaderField: "Authorization")
82 + }
83 + completionHandler(request)
84 + }
85 +
86 + func urlSession(
87 + _ session: URLSession, dataTask: URLSessionDataTask,
88 + didReceive response: URLResponse,
89 + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
90 + ) {
91 + guard let http = response as? HTTPURLResponse else {
92 + rejectedByStatus = true
93 + completionHandler(.cancel)
94 + return
95 + }
96 + status = http.statusCode
97 + switch http.statusCode {
98 + case 200:
99 + // Fresh body (or the server ignored our Range): restart the file.
100 + received = 0
101 + FileManager.default.createFile(atPath: partial.path, contents: nil)
102 + handle = try? FileHandle(forWritingTo: partial)
103 + completionHandler(handle == nil ? .cancel : .allow)
104 + case 206:
105 + handle = try? FileHandle(forWritingTo: partial)
106 + _ = try? handle?.seekToEnd()
107 + completionHandler(handle == nil ? .cancel : .allow)
108 + default:
109 + rejectedByStatus = true
110 + completionHandler(.cancel)
111 + }
112 + }
113 +
114 + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
115 + guard let handle else { return }
116 + do {
117 + try handle.write(contentsOf: data)
118 + received += Int64(data.count)
119 + } catch {
120 + dataTask.cancel()
121 + return
122 + }
123 + let now = ContinuousClock.now
124 + if lastReport.duration(to: now) > .milliseconds(100) {
125 + lastReport = now
126 + onProgress(received)
127 + }
128 + }
129 +
130 + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
131 + try? handle?.close()
132 + handle = nil
133 + onProgress(received)
134 + let c = continuation
135 + continuation = nil
136 + if rejectedByStatus {
137 + c?.resume(throwing: TransferError.badStatus(status))
138 + } else if let error {
139 + if (error as? URLError)?.code == .cancelled {
140 + c?.resume(throwing: CancellationError())
141 + } else {
142 + c?.resume(throwing: error)
143 + }
144 + } else {
145 + c?.resume(returning: received)
146 + }
147 + }
148 +}
added Sources/ZyquoLocal/Hub/HubService.swift +284 −0
@@ -0,0 +1,284 @@
1 +//
2 +// HubService.swift
3 +// Zyquo Local
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +
9 +import Foundation
10 +
11 +/// Live Hugging Face Hub client: search, model info, file listings.
12 +/// Contract documented in docs/MODELS.md §1. All responses are Codable
13 +/// structs — no dictionary spelunking.
14 +struct HubService: Sendable {
15 + /// Search scope shown in the Discover UI.
16 + enum Scope: String, CaseIterable, Sendable {
17 + case featured
18 + case mlxCommunity
19 + case allMLX
20 + }
21 +
22 + enum Sort: String, CaseIterable, Sendable {
23 + case downloads
24 + case likes
25 + case newest
26 +
27 + var apiValue: String {
28 + switch self {
29 + case .downloads: "downloads"
30 + case .likes: "likes"
31 + case .newest: "createdAt"
32 + }
33 + }
34 + }
35 +
36 + /// One search result from /api/models.
37 + struct ModelSummary: Codable, Hashable, Sendable, Identifiable {
38 + var id: String
39 + var likes: Int?
40 + var downloads: Int?
41 + var gated: GatedValue?
42 + var tags: [String]?
43 + var createdAt: Date?
44 + var config: ModelConfigInfo?
45 +
46 + var isGated: Bool { gated?.isGated ?? false }
47 + var architecture: String? { config?.modelType }
48 +
49 + /// True when the architecture is known-supported by the MLX engine.
50 + var isSupportedArchitecture: Bool {
51 + guard let architecture else { return true } // unknown → don't block, warn later
52 + return SupportedArchitectures.contains(architecture)
53 + }
54 + }
55 +
56 + struct ModelConfigInfo: Codable, Hashable, Sendable {
57 + var modelType: String?
58 +
59 + enum CodingKeys: String, CodingKey {
60 + case modelType = "model_type"
61 + }
62 + }
63 +
64 + /// `gated` is `false` or a string ("auto"/"manual").
65 + enum GatedValue: Codable, Hashable, Sendable {
66 + case bool(Bool)
67 + case mode(String)
68 +
69 + var isGated: Bool {
70 + switch self {
71 + case .bool(let b): b
72 + case .mode: true
73 + }
74 + }
75 +
76 + init(from decoder: Decoder) throws {
77 + let container = try decoder.singleValueContainer()
78 + if let b = try? container.decode(Bool.self) {
79 + self = .bool(b)
80 + } else {
81 + self = .mode(try container.decode(String.self))
82 + }
83 + }
84 +
85 + func encode(to encoder: Encoder) throws {
86 + var container = encoder.singleValueContainer()
87 + switch self {
88 + case .bool(let b): try container.encode(b)
89 + case .mode(let s): try container.encode(s)
90 + }
91 + }
92 + }
93 +
94 + /// One file from /tree/main?recursive=true.
95 + struct RepoFile: Codable, Hashable, Sendable {
96 + var type: String
97 + var path: String
98 + var size: Int64?
99 + var lfs: LFSInfo?
100 +
101 + struct LFSInfo: Codable, Hashable, Sendable {
102 + var oid: String?
103 + var size: Int64?
104 + }
105 +
106 + var isFile: Bool { type == "file" }
107 +
108 + /// Files the MLX stack needs (mirrors the package's own filter).
109 + var isModelFile: Bool {
110 + path.hasSuffix(".safetensors") || path.hasSuffix(".json") || path.hasSuffix(".jinja")
111 + }
112 + }
113 +
114 + enum HubError: LocalizedError {
115 + case http(Int, String)
116 + case rateLimited(retryAfterSeconds: Int?)
117 + case gatedOrMissing(String)
118 + case invalidResponse
119 +
120 + var errorDescription: String? {
121 + switch self {
122 + case .http(let code, let repo):
123 + "Hugging Face returned HTTP \(code) for \(repo)."
124 + case .rateLimited(let retry):
125 + "Hugging Face rate limit reached. Try again in \(retry.map { "\($0)s" } ?? "a moment")."
126 + case .gatedOrMissing(let repo):
127 + "\(repo) requires access approval or does not exist. For gated models (Llama, Gemma), add a Hugging Face token in Settings."
128 + case .invalidResponse:
129 + "Unexpected response from Hugging Face."
130 + }
131 + }
132 + }
133 +
134 + var token: String?
135 +
136 + private static let base = URL(string: "https://huggingface.co")!
137 +
138 + private var session: URLSession { URLSession.shared }
139 +
140 + private func request(_ url: URL) -> URLRequest {
141 + var request = URLRequest(url: url)
142 + request.setValue("ZyquoLocal/0.1.0", forHTTPHeaderField: "User-Agent")
143 + if let token, !token.isEmpty {
144 + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
145 + }
146 + return request
147 + }
148 +
149 + private static let decoder: JSONDecoder = {
150 + let decoder = JSONDecoder()
151 + decoder.dateDecodingStrategy = .custom { d in
152 + let raw = try d.singleValueContainer().decode(String.self)
153 + if let date = try? Date(raw, strategy: Date.ISO8601FormatStyle(includingFractionalSeconds: true)) {
154 + return date
155 + }
156 + if let date = try? Date(raw, strategy: .iso8601) {
157 + return date
158 + }
159 + throw DecodingError.dataCorrupted(.init(codingPath: d.codingPath, debugDescription: "Bad date \(raw)"))
160 + }
161 + return decoder
162 + }()
163 +
164 + private func get<T: Decodable>(_ url: URL, as type: T.Type, context: String) async throws -> T {
165 + let (data, response) = try await session.data(for: request(url))
166 + guard let http = response as? HTTPURLResponse else { throw HubError.invalidResponse }
167 + switch http.statusCode {
168 + case 200:
169 + return try Self.decoder.decode(T.self, from: data)
170 + case 401, 403:
171 + throw HubError.gatedOrMissing(context)
172 + case 429:
173 + let retry = http.value(forHTTPHeaderField: "retry-after").flatMap(Int.init)
174 + throw HubError.rateLimited(retryAfterSeconds: retry)
175 + default:
176 + throw HubError.http(http.statusCode, context)
177 + }
178 + }
179 +
180 + // MARK: - Search
181 +
182 + /// Live Hub search. `scope .allMLX` searches all of HF filtered to the
183 + /// `mlx` library tag; architecture compatibility is checked via
184 + /// `config.model_type` (requested with config=true).
185 + func search(
186 + query: String,
187 + scope: Scope,
188 + sort: Sort = .downloads,
189 + limit: Int = 40
190 + ) async throws -> [ModelSummary] {
191 + var components = URLComponents(
192 + url: Self.base.appendingPathComponent("api/models"), resolvingAgainstBaseURL: false)!
193 + var items = [
194 + URLQueryItem(name: "pipeline_tag", value: "text-generation"),
195 + URLQueryItem(name: "sort", value: sort.apiValue),
196 + URLQueryItem(name: "direction", value: "-1"),
197 + URLQueryItem(name: "limit", value: String(limit)),
198 + URLQueryItem(name: "config", value: "true"),
199 + ]
200 + if !query.isEmpty {
201 + items.append(URLQueryItem(name: "search", value: query))
202 + }
203 + switch scope {
204 + case .featured, .mlxCommunity:
205 + items.append(URLQueryItem(name: "author", value: "mlx-community"))
206 + case .allMLX:
207 + items.append(URLQueryItem(name: "filter", value: "mlx"))
208 + }
209 + components.queryItems = items
210 + return try await get([ModelSummary].self, from: components.url!, context: "search")
211 + }
212 +
213 + private func get<T: Decodable>(_ type: T.Type, from url: URL, context: String) async throws -> T {
214 + try await get(url, as: type, context: context)
215 + }
216 +
217 + // MARK: - Files
218 +
219 + /// Full file listing with sizes for a repo (paginates if needed).
220 + func files(of repoID: String) async throws -> [RepoFile] {
221 + var all: [RepoFile] = []
222 + var url: URL? = Self.base.appendingPathComponent("api/models/\(repoID)/tree/main")
223 + .appending(queryItems: [URLQueryItem(name: "recursive", value: "true")])
224 + while let current = url {
225 + let (data, response) = try await session.data(for: request(current))
226 + guard let http = response as? HTTPURLResponse else { throw HubError.invalidResponse }
227 + guard http.statusCode == 200 else {
228 + if http.statusCode == 401 || http.statusCode == 403 {
229 + throw HubError.gatedOrMissing(repoID)
230 + }
231 + throw HubError.http(http.statusCode, repoID)
232 + }
233 + all += try Self.decoder.decode([RepoFile].self, from: data)
234 + url = Self.nextPage(from: http)
235 + }
236 + return all
237 + }
238 +
239 + /// Files required to run the model, with total download size.
240 + func requiredFiles(of repoID: String) async throws -> (files: [RepoFile], totalBytes: Int64) {
241 + let required = try await files(of: repoID).filter { $0.isFile && $0.isModelFile }
242 + let total = required.reduce(Int64(0)) { $0 + ($1.size ?? 0) }
243 + return (required, total)
244 + }
245 +
246 + /// Download URL for one file of a repo.
247 + static func resolveURL(repoID: String, path: String) -> URL {
248 + base.appendingPathComponent("\(repoID)/resolve/main/\(path)")
249 + }
250 +
251 + /// Parses RFC-5988 Link header for cursor pagination.
252 + private static func nextPage(from response: HTTPURLResponse) -> URL? {
253 + guard let link = response.value(forHTTPHeaderField: "Link") else { return nil }
254 + for part in link.split(separator: ",") {
255 + let segments = part.split(separator: ";").map { $0.trimmingCharacters(in: .whitespaces) }
256 + guard segments.count >= 2, segments.contains(where: { $0 == "rel=\"next\"" }),
257 + let urlPart = segments.first, urlPart.hasPrefix("<"), urlPart.hasSuffix(">")
258 + else { continue }
259 + return URL(string: String(urlPart.dropFirst().dropLast()))
260 + }
261 + return nil
262 + }
263 +}
264 +
265 +/// Architectures supported by the MLX Swift LLM layer (verified list from
266 +/// docs/MLX-RESEARCH.md §4 — LLMTypeRegistry @ mlx-swift-lm 3.31.4).
267 +enum SupportedArchitectures {
268 + static let all: Set<String> = [
269 + "mistral", "mixtral", "llama", "phi", "phi3", "phimoe", "gemma", "gemma2",
270 + "gemma3", "gemma3_text", "gemma3n", "gemma4", "gemma4_unified", "gemma4_text",
271 + "qwen2", "qwen3", "qwen3_moe", "qwen3_next", "qwen3_5", "qwen3_5_moe",
272 + "qwen3_5_text", "minicpm", "starcoder2", "cohere", "openelm", "internlm2",
273 + "deepseek_v3", "granite", "granitemoehybrid", "mimo", "mimo_v2_flash",
274 + "minimax", "glm4", "glm4_moe", "glm4_moe_lite", "acereason", "falcon_h1",
275 + "bitnet", "smollm3", "ernie4_5", "lfm2", "baichuan_m1", "exaone4", "gpt_oss",
276 + "lille-130m", "olmoe", "olmo2", "olmo3", "bailing_moe", "lfm2_moe",
277 + "nanochat", "nemotron_h", "afmoe", "jamba", "mamba2", "mistral3", "apertus",
278 + "nemotron_labs_diffusion",
279 + ]
280 +
281 + static func contains(_ architecture: String) -> Bool {
282 + all.contains(architecture)
283 + }
284 +}
added Sources/ZyquoLocal/Hub/ModelStore.swift +180 −0
@@ -0,0 +1,180 @@
1 +//
2 +// ModelStore.swift
3 +// Zyquo Local
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +
9 +import AppKit
10 +import Foundation
11 +import Observation
12 +
13 +/// The on-disk model library: scans the Models folder, validates model
14 +/// directories, tracks sizes and last-used dates, deletes models.
15 +@MainActor
16 +@Observable
17 +final class ModelStore {
18 + private(set) var models: [LocalModel] = []
19 +
20 + /// Root: ~/Library/Application Support/ZyquoLocal/Models
21 + let modelsRoot: URL
22 +
23 + /// Sidecar metadata (last used, per-model params) keyed by repoID.
24 + private var meta: [String: ModelMeta] = [:]
25 +
26 + struct ModelMeta: Codable {
27 + var lastUsed: Date?
28 + var defaultParams: GenerationParams?
29 + }
30 +
31 + var totalSizeBytes: Int64 {
32 + models.reduce(0) { $0 + $1.sizeBytes }
33 + }
34 +
35 + init(root: URL = PersistenceService.appSupportDirectory.appendingPathComponent("Models")) {
36 + self.modelsRoot = root
37 + try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
38 + loadMeta()
39 + rescan()
40 + }
41 +
42 + /// Directory a repo downloads into: Models/{org}/{repo}.
43 + func directory(for repoID: String) -> URL {
44 + modelsRoot.appendingPathComponent(repoID)
45 + }
46 +
47 + func model(for repoID: String) -> LocalModel? {
48 + models.first { $0.repoID == repoID }
49 + }
50 +
51 + /// Required files for a valid model directory (docs/MLX-RESEARCH.md §3).
52 + static func isValidModelDirectory(_ dir: URL) -> Bool {
53 + let fm = FileManager.default
54 + guard fm.fileExists(atPath: dir.appendingPathComponent("config.json").path) else { return false }
55 + guard fm.fileExists(atPath: dir.appendingPathComponent("tokenizer_config.json").path) else { return false }
56 + guard fm.fileExists(atPath: dir.appendingPathComponent("tokenizer.json").path) else { return false }
57 + guard let contents = try? fm.contentsOfDirectory(atPath: dir.path) else { return false }
58 + return contents.contains { $0.hasSuffix(".safetensors") && !$0.hasSuffix(".partial") }
59 + }
60 +
61 + /// Scans Models/{org}/{repo} two levels deep and rebuilds the library.
62 + func rescan() {
63 + let fm = FileManager.default
64 + var found: [LocalModel] = []
65 + let orgs = (try? fm.contentsOfDirectory(
66 + at: modelsRoot, includingPropertiesForKeys: nil,
67 + options: .skipsHiddenFiles)) ?? []
68 + for org in orgs where org.hasDirectoryPath || (try? org.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true {
69 + let repos = (try? fm.contentsOfDirectory(
70 + at: org, includingPropertiesForKeys: nil,
71 + options: .skipsHiddenFiles)) ?? []
72 + for repo in repos {
73 + guard (try? repo.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else { continue }
74 + guard Self.isValidModelDirectory(repo) else { continue }
75 + let repoID = "\(org.lastPathComponent)/\(repo.lastPathComponent)"
76 + let size = (try? fm.allocatedSizeOfDirectory(at: repo)) ?? 0
77 + let config = Self.readConfig(directory: repo)
78 + found.append(LocalModel(
79 + repoID: repoID,
80 + directory: repo,
81 + sizeBytes: size,
82 + architecture: config.architecture,
83 + quantization: Self.quantLabel(repoID: repoID, config: config),
84 + contextWindow: InferenceEngine.readContextWindow(directory: repo),
85 + lastUsed: meta[repoID]?.lastUsed,
86 + defaultParams: meta[repoID]?.defaultParams
87 + ))
88 + }
89 + }
90 + models = found.sorted { ($0.lastUsed ?? .distantPast) > ($1.lastUsed ?? .distantPast) }
91 + }
92 +
93 + /// Deletes a model directory. Returns the reclaimed bytes.
94 + @discardableResult
95 + func delete(repoID: String) -> Int64 {
96 + guard let model = model(for: repoID) else { return 0 }
97 + let reclaimed = model.sizeBytes
98 + try? FileManager.default.removeItem(at: model.directory)
99 + // Prune the org folder when empty.
100 + let org = model.directory.deletingLastPathComponent()
101 + if let rest = try? FileManager.default.contentsOfDirectory(atPath: org.path), rest.isEmpty {
102 + try? FileManager.default.removeItem(at: org)
103 + }
104 + meta[repoID] = nil
105 + saveMeta()
106 + rescan()
107 + return reclaimed
108 + }
109 +
110 + func revealInFinder(repoID: String) {
111 + guard let model = model(for: repoID) else { return }
112 + NSWorkspace.shared.activateFileViewerSelecting([model.directory])
113 + }
114 +
115 + func markUsed(repoID: String) {
116 + var m = meta[repoID] ?? ModelMeta()
117 + m.lastUsed = Date()
118 + meta[repoID] = m
119 + saveMeta()
120 + if let i = models.firstIndex(where: { $0.repoID == repoID }) {
121 + models[i].lastUsed = m.lastUsed
122 + }
123 + }
124 +
125 + func setDefaultParams(_ params: GenerationParams?, for repoID: String) {
126 + var m = meta[repoID] ?? ModelMeta()
127 + m.defaultParams = params
128 + meta[repoID] = m
129 + saveMeta()
130 + if let i = models.firstIndex(where: { $0.repoID == repoID }) {
131 + models[i].defaultParams = params
132 + }
133 + }
134 +
135 + // MARK: - Config parsing
136 +
137 + struct ConfigInfo {
138 + var architecture: String?
139 + var quantBits: Int?
140 + }
141 +
142 + static func readConfig(directory: URL) -> ConfigInfo {
143 + let url = directory.appendingPathComponent("config.json")
144 + guard let data = try? Data(contentsOf: url),
145 + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
146 + else { return ConfigInfo() }
147 + var bits: Int?
148 + if let quant = json["quantization"] as? [String: Any] {
149 + bits = quant["bits"] as? Int
150 + }
151 + return ConfigInfo(architecture: json["model_type"] as? String, quantBits: bits)
152 + }
153 +
154 + /// "4bit", "8bit", "bf16"… parsed from repo name, falling back to config.
155 + static func quantLabel(repoID: String, config: ConfigInfo) -> String? {
156 + let name = repoID.lowercased()
157 + for candidate in ["4bit", "5bit", "6bit", "8bit", "bf16", "fp16", "mxfp4"] {
158 + if name.contains(candidate) { return candidate }
159 + }
160 + if let bits = config.quantBits { return "\(bits)bit" }
161 + return nil
162 + }
163 +
164 + // MARK: - Meta persistence
165 +
166 + private var metaURL: URL {
167 + PersistenceService.appSupportDirectory.appendingPathComponent("library-meta.json")
168 + }
169 +
170 + private func loadMeta() {
171 + guard let data = try? Data(contentsOf: metaURL),
172 + let decoded = try? JSONDecoder().decode([String: ModelMeta].self, from: data)
173 + else { return }
174 + meta = decoded
175 + }
176 +
177 + private func saveMeta() {
178 + try? JSONEncoder().encode(meta).write(to: metaURL, options: .atomic)
179 + }
180 +}
added Sources/ZyquoLocal/Services/ModelCatalog.swift +162 −0
@@ -0,0 +1,162 @@
1 +//
2 +// ModelCatalog.swift
3 +// Zyquo Local
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +
9 +import Foundation
10 +
11 +/// The curated Featured catalog. GENERATED FROM docs/MODELS.md §3 — the two
12 +/// must never drift apart: any Phase 7 verification fix updates both together.
13 +/// Sizes are decimal GB, live-verified on the Hub on 2026-07-30.
14 +struct CatalogModel: Identifiable, Hashable, Sendable {
15 + enum Category: String, CaseIterable, Sendable {
16 + case tiny
17 + case mid
18 + case large
19 + case coding
20 + case reasoning
21 + case general
22 + }
23 +
24 + var repoID: String
25 + var params: String
26 + var quant: String
27 + var sizeGB: Double
28 + var minRAMGB: Int
29 + var categories: [Category]
30 + var blurb: String
31 +
32 + var id: String { repoID }
33 +
34 + var sizeBytes: Int64 { Int64(sizeGB * 1_000_000_000) }
35 +
36 + /// RAM verdict for THIS Mac.
37 + var verdict: MemoryAdvisor.Verdict {
38 + MemoryAdvisor.verdict(weightsBytes: sizeBytes)
39 + }
40 +}
41 +
42 +enum ModelCatalog {
43 + /// Starter picks for the empty-state onboarding hero, chosen for this
44 + /// Mac's RAM from the featured list (smallest that fit comfortably).
45 + static func starterPicks(count: Int = 4) -> [CatalogModel] {
46 + let ramGB = Int(MemoryAdvisor.physicalMemoryBytes / 1_073_741_824)
47 + var picks: [CatalogModel] = []
48 + // One instant-download tiny, then the best generalists that fit.
49 + if let tiny = featured.first(where: { $0.repoID.contains("Qwen3-0.6B") }) {
50 + picks.append(tiny)
51 + }
52 + let comfortable = featured
53 + .filter { $0.minRAMGB <= ramGB && $0.verdict == .fits && !picks.contains($0) }
54 + .sorted { $0.sizeGB > $1.sizeGB }
55 + for model in comfortable where picks.count < count {
56 + picks.append(model)
57 + }
58 + return picks
59 + }
60 +
61 + static let featured: [CatalogModel] = [
62 + // ── Tiny (≤4B) ──────────────────────────────────────────────────────
63 + CatalogModel(repoID: "mlx-community/Qwen3-0.6B-4bit", params: "0.6B", quant: "4bit",
64 + sizeGB: 0.35, minRAMGB: 8, categories: [.tiny, .general],
65 + blurb: "Smallest useful chat model; instant loads."),
66 + CatalogModel(repoID: "mlx-community/LFM2.5-1.2B-Instruct-4bit", params: "1.2B", quant: "4bit",
67 + sizeGB: 0.66, minRAMGB: 8, categories: [.tiny, .general],
68 + blurb: "Liquid AI's 2026 edge model; punchy and very fast."),
69 + CatalogModel(repoID: "mlx-community/Llama-3.2-1B-Instruct-4bit", params: "1B", quant: "4bit",
70 + sizeGB: 0.71, minRAMGB: 8, categories: [.tiny, .general],
71 + blurb: "The classic 1B; most-downloaded tiny LLM."),
72 + CatalogModel(repoID: "mlx-community/gemma-3-1b-it-qat-4bit", params: "1B", quant: "QAT-4bit",
73 + sizeGB: 0.77, minRAMGB: 8, categories: [.tiny, .general],
74 + blurb: "Google QAT checkpoint — best quality-per-byte at 1B."),
75 + CatalogModel(repoID: "mlx-community/Qwen3-1.7B-4bit", params: "1.7B", quant: "4bit",
76 + sizeGB: 0.98, minRAMGB: 8, categories: [.tiny, .general, .reasoning],
77 + blurb: "Hybrid thinking modes in under 1 GB."),
78 + CatalogModel(repoID: "mlx-community/SmolLM3-3B-4bit", params: "3B", quant: "4bit",
79 + sizeGB: 1.75, minRAMGB: 8, categories: [.tiny, .general],
80 + blurb: "HF's fully-open 3B; long context, optional reasoning."),
81 + CatalogModel(repoID: "mlx-community/Llama-3.2-3B-Instruct-4bit", params: "3B", quant: "4bit",
82 + sizeGB: 1.82, minRAMGB: 8, categories: [.tiny, .general],
83 + blurb: "The default “runs anywhere” pick."),
84 + CatalogModel(repoID: "mlx-community/Qwen3-4B-Instruct-2507-4bit", params: "4B", quant: "4bit",
85 + sizeGB: 2.28, minRAMGB: 8, categories: [.tiny, .general],
86 + blurb: "2507 refresh — best ≤4B all-rounder."),
87 + CatalogModel(repoID: "mlx-community/gemma-3-4b-it-qat-4bit", params: "4B", quant: "QAT-4bit",
88 + sizeGB: 3.03, minRAMGB: 8, categories: [.tiny, .general],
89 + blurb: "Vision-capable 4B with QAT quality."),
90 +
91 + // ── Mid (7–20B) ─────────────────────────────────────────────────────
92 + CatalogModel(repoID: "mlx-community/Llama-3.1-8B-Instruct-4bit", params: "8B", quant: "4bit",
93 + sizeGB: 4.53, minRAMGB: 16, categories: [.mid, .general],
94 + blurb: "The reference 8B; huge prompt/finetune ecosystem."),
95 + CatalogModel(repoID: "mlx-community/Qwen3-8B-4bit", params: "8B", quant: "4bit",
96 + sizeGB: 4.62, minRAMGB: 16, categories: [.mid, .general, .reasoning],
97 + blurb: "Best-selling 8B; thinking mode on demand."),
98 + CatalogModel(repoID: "mlx-community/gemma-3-12b-it-qat-4bit", params: "12B", quant: "QAT-4bit",
99 + sizeGB: 8.07, minRAMGB: 16, categories: [.mid, .general],
100 + blurb: "Sweet spot for 16 GB Macs; strong writing."),
101 + CatalogModel(repoID: "mlx-community/Qwen3.5-9B-OptiQ-4bit", params: "9B", quant: "OptiQ-4bit",
102 + sizeGB: 8.22, minRAMGB: 16, categories: [.mid, .general],
103 + blurb: "2026 Qwen3.5 generation; top mid-size quality."),
104 + CatalogModel(repoID: "mlx-community/phi-4-4bit", params: "14.7B", quant: "4bit",
105 + sizeGB: 8.26, minRAMGB: 16, categories: [.mid, .general, .reasoning],
106 + blurb: "Microsoft dense 14B; excels at math and STEM."),
107 + CatalogModel(repoID: "mlx-community/Qwen3-14B-4bit", params: "14B", quant: "4bit",
108 + sizeGB: 8.32, minRAMGB: 16, categories: [.mid, .general, .reasoning],
109 + blurb: "Stronger sibling of Qwen3-8B; 16 GB flagship."),
110 + CatalogModel(repoID: "mlx-community/gpt-oss-20b-MXFP4-Q8", params: "20.9B MoE", quant: "MXFP4-Q8",
111 + sizeGB: 12.10, minRAMGB: 16, categories: [.mid, .general, .reasoning],
112 + blurb: "OpenAI's open-weights MoE; most-downloaded LLM in the org."),
113 +
114 + // ── Large (24B+) ────────────────────────────────────────────────────
115 + CatalogModel(repoID: "mlx-community/Mistral-Small-3.2-24B-Instruct-2506-4bit", params: "24B", quant: "4bit",
116 + sizeGB: 13.28, minRAMGB: 24, categories: [.large, .general],
117 + blurb: "Fast dense 24B, low hallucination, good tool use."),
118 + CatalogModel(repoID: "mlx-community/gemma-3-27b-it-qat-4bit", params: "27B", quant: "QAT-4bit",
119 + sizeGB: 16.87, minRAMGB: 32, categories: [.large, .general],
120 + blurb: "Gemma 3 flagship with QAT; superb chat quality."),
121 + CatalogModel(repoID: "mlx-community/GLM-4.7-Flash-4bit", params: "30B MoE", quant: "4bit",
122 + sizeGB: 16.87, minRAMGB: 32, categories: [.large, .general, .coding],
123 + blurb: "Zhipu's 2026 fast MoE; strong agentic coding."),
124 + CatalogModel(repoID: "mlx-community/Qwen3-30B-A3B-Instruct-2507-4bit", params: "30B-A3B MoE", quant: "4bit",
125 + sizeGB: 17.20, minRAMGB: 32, categories: [.large, .general],
126 + blurb: "3B active params → big-model quality at small-model speed."),
127 + CatalogModel(repoID: "mlx-community/Qwen3-32B-4bit", params: "32B", quant: "4bit",
128 + sizeGB: 18.45, minRAMGB: 32, categories: [.large, .general, .reasoning],
129 + blurb: "Dense 32B with thinking; slower but deeper than the MoE."),
130 + CatalogModel(repoID: "mlx-community/Qwen3.6-27B-OptiQ-4bit", params: "27B", quant: "OptiQ-4bit",
131 + sizeGB: 20.00, minRAMGB: 32, categories: [.large, .general],
132 + blurb: "2026 Qwen3.6 dense; MTP head ≈1.4× faster decode."),
133 + CatalogModel(repoID: "mlx-community/Qwen3.6-35B-A3B-OptiQ-4bit", params: "35B-A3B MoE", quant: "OptiQ-4bit",
134 + sizeGB: 24.69, minRAMGB: 48, categories: [.large, .general],
135 + blurb: "2026 successor to Qwen3-30B-A3B."),
136 + CatalogModel(repoID: "mlx-community/Llama-3.3-70B-Instruct-4bit", params: "70B", quant: "4bit",
137 + sizeGB: 39.71, minRAMGB: 64, categories: [.large, .general],
138 + blurb: "The 70B reference; 64 GB+ Macs only."),
139 +
140 + // ── Coding ──────────────────────────────────────────────────────────
141 + CatalogModel(repoID: "mlx-community/Qwen2.5-Coder-7B-Instruct-4bit", params: "7B", quant: "4bit",
142 + sizeGB: 4.30, minRAMGB: 16, categories: [.coding],
143 + blurb: "The default small local code model."),
144 + CatalogModel(repoID: "mlx-community/Qwen2.5-Coder-14B-Instruct-4bit", params: "14B", quant: "4bit",
145 + sizeGB: 8.32, minRAMGB: 16, categories: [.coding],
146 + blurb: "Noticeably better completions on 16 GB Macs."),
147 + CatalogModel(repoID: "mlx-community/Qwen3-Coder-30B-A3B-Instruct-4bit", params: "30B-A3B MoE", quant: "4bit",
148 + sizeGB: 17.20, minRAMGB: 32, categories: [.coding, .large],
149 + blurb: "Best local coding model for 32 GB; fast agentic loops."),
150 +
151 + // ── Reasoning ───────────────────────────────────────────────────────
152 + CatalogModel(repoID: "mlx-community/DeepSeek-R1-0528-Qwen3-8B-4bit", params: "8B", quant: "4bit",
153 + sizeGB: 4.62, minRAMGB: 16, categories: [.reasoning],
154 + blurb: "R1-0528 distill onto Qwen3-8B; visible chain-of-thought."),
155 + CatalogModel(repoID: "mlx-community/DeepSeek-R1-Distill-Qwen-14B-4bit", params: "14B", quant: "4bit",
156 + sizeGB: 8.32, minRAMGB: 16, categories: [.reasoning],
157 + blurb: "Most-downloaded R1 distill; great math on 16 GB."),
158 + CatalogModel(repoID: "mlx-community/Qwen3-30B-A3B-Thinking-2507-4bit", params: "30B-A3B MoE", quant: "4bit",
159 + sizeGB: 17.20, minRAMGB: 32, categories: [.reasoning, .large],
160 + blurb: "Current best local reasoner under 32 GB."),
161 + ]
162 +}
added Sources/ZyquoLocal/Services/PersistenceService.swift +76 −0
@@ -0,0 +1,76 @@
1 +//
2 +// PersistenceService.swift
3 +// Zyquo Local
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +
9 +import Foundation
10 +
11 +/// Conversations, personas and settings persisted as JSON under
12 +/// ~/Library/Application Support/ZyquoLocal/.
13 +enum PersistenceService {
14 + static var appSupportDirectory: URL {
15 + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
16 + let dir = base.appendingPathComponent("ZyquoLocal")
17 + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
18 + return dir
19 + }
20 +
21 + private static var conversationsDirectory: URL {
22 + let dir = appSupportDirectory.appendingPathComponent("Conversations")
23 + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
24 + return dir
25 + }
26 +
27 + private static let encoder: JSONEncoder = {
28 + let e = JSONEncoder()
29 + e.dateEncodingStrategy = .iso8601
30 + e.outputFormatting = [.sortedKeys]
31 + return e
32 + }()
33 +
34 + private static let decoder: JSONDecoder = {
35 + let d = JSONDecoder()
36 + d.dateDecodingStrategy = .iso8601
37 + return d
38 + }()
39 +
40 + // MARK: - Conversations (one JSON file per conversation)
41 +
42 + static func loadConversations() -> [Conversation] {
43 + let files = (try? FileManager.default.contentsOfDirectory(
44 + at: conversationsDirectory, includingPropertiesForKeys: nil)) ?? []
45 + return files
46 + .filter { $0.pathExtension == "json" }
47 + .compactMap { url in
48 + guard let data = try? Data(contentsOf: url) else { return nil }
49 + return try? decoder.decode(Conversation.self, from: data)
50 + }
51 + .sorted { $0.updatedAt > $1.updatedAt }
52 + }
53 +
54 + static func save(_ conversation: Conversation) {
55 + let url = conversationsDirectory.appendingPathComponent("\(conversation.id.uuidString).json")
56 + try? encoder.encode(conversation).write(to: url, options: .atomic)
57 + }
58 +
59 + static func delete(conversationID: UUID) {
60 + let url = conversationsDirectory.appendingPathComponent("\(conversationID.uuidString).json")
61 + try? FileManager.default.removeItem(at: url)
62 + }
63 +
64 + // MARK: - Generic single-document storage (settings, personas, prompts)
65 +
66 + static func loadDocument<T: Decodable>(_ type: T.Type, named name: String) -> T? {
67 + let url = appSupportDirectory.appendingPathComponent("\(name).json")
68 + guard let data = try? Data(contentsOf: url) else { return nil }
69 + return try? decoder.decode(T.self, from: data)
70 + }
71 +
72 + static func saveDocument<T: Encodable>(_ value: T, named name: String) {
73 + let url = appSupportDirectory.appendingPathComponent("\(name).json")
74 + try? encoder.encode(value).write(to: url, options: .atomic)
75 + }
76 +}
modified docs/PLAN.md +13 −1
@@ -63,7 +63,19 @@ Executable dispatches --poc and exits 64 as designed.
63 63 first-class; `<think>` content streams through (reasoning display feeds on it
64 64 in Phase 6). Multi-turn KV reuse + cancellation get their formal end-to-end
65 65 tests in the Phase 7 harness.
66 ## Phase 3 — Hub browse & download — pending
66 +## Phase 3 — Hub browse & download — ✅ DONE (2026-07-30)
67 +
68 +- [x] HubService: live search (scopes featured/mlx-community/all-MLX, config=true arch check), tree file listing with Link-header pagination, Codable structs throughout, HF-token support, human-readable error surfaces (gated/429/…)
69 +- [x] SupportedArchitectures shipping the verified LLMTypeRegistry list
70 +- [x] FileTransfer: delegate-backed chunked transport, Range resume (206), transparent restart (200), 416 handling, cross-host Authorization strip, cancellation
71 +- [x] DownloadManager: queued, 2 concurrent files, pause/resume/cancel per model, transient-drop retry with backoff, atomic .partial → final, size verification, disk pre-check (5 % headroom), manifest persisted across relaunch, speed EMA + ETA
72 +- [x] ModelStore: scan/validate (required-file check), size on disk, delete + reclaim, reveal in Finder, last-used + per-model default params sidecar
73 +- [x] Services: PersistenceService (JSON per conversation + generic docs), ModelCatalog (30 featured entries mirroring docs/MODELS.md, starter picks per RAM)
74 +- [x] PHASE GATE (--hub-poc, isolated temp store): search ✅ listing ✅ download 41 MB/s ✅ pause at 27 % ✅ Range-resume from 100 MB ✅ verify ✅ scan (arch qwen3, 4bit, ctx 40960, Fits) ✅ delete ✅
75 +
76 +**Checkpoint:** Zero warnings. Coherence sweep: headers green, `LocalModel` is
77 +the single term, folder structure matches the Phase 2 tree (DesignSystem/,
78 +ViewModels/, Views/ arrive with Phases 4/6).
67 79 ## Phase 4 — Design system & UI spec — pending
68 80 ## Phase 5 — App icon — pending
69 81 ## Phase 6 — Features / full UI — pending
70 82