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%

phase2: InferenceEngine actor + ChatSession + MemoryAdvisor; PoC gate green (130 tok/s)

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

Showing 11 changed files with +804 and −5

modified Sources/ZyquoLocal/App/PoCRunner.swift +84 −4
@@ -8,11 +8,91 @@
8 8
9 9 import Foundation
10 10
11 /// CLI proof-of-concept mode: `ZyquoLocal --poc <model-dir> "<prompt>"`.
12 /// Fully implemented in Phase 2 alongside the inference engine.
11 +/// CLI proof-of-concept mode — the Phase 2 gate:
12 +/// `ZyquoLocal --poc <model-dir> "<prompt>"` loads an MLX model from a local
13 +/// directory and streams generated tokens to stdout, ending with stats.
13 14 enum PoCRunner {
14 15 static func run(arguments: [String]) async {
15 FileHandle.standardError.write(Data("--poc arrives with the Phase 2 inference engine.\n".utf8))
16 exit(64)
16 + guard let flagIndex = arguments.firstIndex(of: "--poc"),
17 + arguments.count >= flagIndex + 3
18 + else {
19 + err("usage: ZyquoLocal --poc <model-dir> \"<prompt>\"\n")
20 + exit(64)
21 + }
22 + let directory = URL(fileURLWithPath: arguments[flagIndex + 1])
23 + let prompt = arguments[flagIndex + 2]
24 +
25 + let size = (try? FileManager.default.allocatedSizeOfDirectory(at: directory)) ?? 0
26 + let model = LocalModel(
27 + repoID: directory.pathComponents.suffix(2).joined(separator: "/"),
28 + directory: directory,
29 + sizeBytes: size
30 + )
31 +
32 + let engine = InferenceEngine()
33 + do {
34 + err("Loading \(model.repoID) (\(ByteCountFormatter.string(fromByteCount: size, countStyle: .file)))…\n")
35 + let loadStart = Date()
36 + try await engine.load(model: model)
37 + err(String(format: "Loaded in %.2fs. Generating…\n\n", Date().timeIntervalSince(loadStart)))
38 +
39 + let conversation = Conversation(modelID: model.repoID)
40 + try await engine.startSession(conversation: conversation)
41 +
42 + var stats: GenerationStats?
43 + let stream = try await engine.generate(prompt: prompt, params: GenerationParams())
44 + for try await event in stream {
45 + switch event {
46 + case .token(let text):
47 + out(text)
48 + case .stats(let s):
49 + stats = s
50 + case .finished(let reason):
51 + out("\n")
52 + if let s = stats {
53 + err(String(
54 + format: "\n⚡ %.1f tok/s · %d tokens · %.2fs to first token · prompt %d tokens · peak %@ · stop: %@\n",
55 + s.tokensPerSecond,
56 + s.generationTokenCount,
57 + s.timeToFirstToken,
58 + s.promptTokenCount,
59 + ByteCountFormatter.string(fromByteCount: Int64(s.peakMemoryBytes), countStyle: .memory),
60 + reason.rawValue
61 + ))
62 + }
63 + }
64 + }
65 + await engine.unload()
66 + exit(0)
67 + } catch {
68 + err("error: \(error.localizedDescription)\n")
69 + exit(1)
70 + }
71 + }
72 +
73 + private static func out(_ text: String) {
74 + FileHandle.standardOutput.write(Data(text.utf8))
75 + }
76 +
77 + private static func err(_ text: String) {
78 + FileHandle.standardError.write(Data(text.utf8))
79 + }
80 +}
81 +
82 +extension FileManager {
83 + /// Total allocated size of a directory tree in bytes.
84 + func allocatedSizeOfDirectory(at url: URL) throws -> Int64 {
85 + var total: Int64 = 0
86 + let keys: Set<URLResourceKey> = [.totalFileAllocatedSizeKey, .isRegularFileKey]
87 + guard let enumerator = enumerator(at: url, includingPropertiesForKeys: Array(keys)) else {
88 + return 0
89 + }
90 + for case let file as URL in enumerator {
91 + let values = try file.resourceValues(forKeys: keys)
92 + if values.isRegularFile == true {
93 + total += Int64(values.totalFileAllocatedSize ?? 0)
94 + }
95 + }
96 + return total
17 97 }
18 98 }
added Sources/ZyquoLocal/Engine/ChatSession.swift +110 −0
@@ -0,0 +1,110 @@
1 +//
2 +// ChatSession.swift
3 +// Zyquo Local
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +
9 +import Foundation
10 +import MLXLMCommon
11 +
12 +/// Zyquo Local's multi-turn session: converts persisted conversation history
13 +/// into the model's chat-template form, truncates oldest turns when the
14 +/// context window would overflow (always keeping the system prompt), and
15 +/// reuses the KV cache across turns via the underlying MLX session.
16 +///
17 +/// Not thread-safe by design — owned and accessed only by `InferenceEngine`.
18 +final class ChatSession {
19 + /// Conversation this session is bound to.
20 + let conversationID: UUID
21 + private let contextWindow: Int
22 + private let mlxSession: MLXLMCommon.ChatSession
23 +
24 + /// Rough chars-per-token estimate used for truncation budgeting.
25 + private static let charsPerToken = 3.5
26 + /// Fraction of the context window budgeted for history (rest is reserved
27 + /// for the generated response).
28 + private static let historyBudgetFraction = 0.7
29 +
30 + /// Number of tokens estimated to be used by history + system prompt.
31 + private(set) var estimatedHistoryTokens: Int
32 +
33 + init(
34 + container: ModelContainer,
35 + conversation: Conversation,
36 + contextWindow: Int,
37 + params: GenerationParams
38 + ) {
39 + self.conversationID = conversation.id
40 + self.contextWindow = contextWindow
41 +
42 + let truncated = Self.truncatedHistory(
43 + messages: conversation.messages,
44 + contextWindow: contextWindow
45 + )
46 + self.estimatedHistoryTokens = Self.estimateTokens(
47 + truncated.map(\.content).joined() + (conversation.systemPrompt ?? "")
48 + )
49 +
50 + let history: [Chat.Message] = truncated.map { message in
51 + switch message.role {
52 + case .system: .system(message.content)
53 + case .user: .user(message.content)
54 + case .assistant: .assistant(message.content)
55 + }
56 + }
57 + self.mlxSession = MLXLMCommon.ChatSession(
58 + container,
59 + instructions: conversation.systemPrompt,
60 + history: history,
61 + generateParameters: params.toMLX()
62 + )
63 + }
64 +
65 + /// Streams a response to a new user prompt, honoring per-call parameters
66 + /// without losing the KV cache.
67 + func stream(prompt: String, params: GenerationParams) -> AsyncThrowingStream<Generation, Error> {
68 + mlxSession.generateParameters = params.toMLX()
69 + estimatedHistoryTokens += Self.estimateTokens(prompt)
70 + return mlxSession.streamDetails(to: prompt)
71 + }
72 +
73 + /// Estimated context usage in tokens (for the UI context bar).
74 + var estimatedContextUsage: (used: Int, window: Int) {
75 + (estimatedHistoryTokens, contextWindow)
76 + }
77 +
78 + func noteResponse(_ text: String) {
79 + estimatedHistoryTokens += Self.estimateTokens(text)
80 + }
81 +
82 + // MARK: - Truncation
83 +
84 + static func estimateTokens(_ text: String) -> Int {
85 + Int(Double(text.count) / charsPerToken) + 1
86 + }
87 +
88 + /// Drops oldest non-system turns until the estimated history fits the
89 + /// budgeted share of the context window. The system prompt (a leading
90 + /// system message, if any) is always kept.
91 + static func truncatedHistory(messages: [Message], contextWindow: Int) -> [Message] {
92 + let budget = Int(Double(contextWindow) * historyBudgetFraction)
93 + var system: [Message] = []
94 + var turns: [Message] = []
95 + for m in messages {
96 + if m.role == .system && turns.isEmpty {
97 + system.append(m)
98 + } else {
99 + turns.append(m)
100 + }
101 + }
102 + func total(_ list: [Message]) -> Int {
103 + list.reduce(0) { $0 + estimateTokens($1.content) }
104 + }
105 + while turns.count > 1, total(system) + total(turns) > budget {
106 + turns.removeFirst()
107 + }
108 + return system + turns
109 + }
110 +}
added Sources/ZyquoLocal/Engine/GenerationParams.swift +50 −0
@@ -0,0 +1,50 @@
1 +//
2 +// GenerationParams.swift
3 +// Zyquo Local
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +
9 +import Foundation
10 +import MLXLMCommon
11 +
12 +/// App-level, persistable generation parameters. Mapped to MLX
13 +/// `GenerateParameters` at the engine boundary — inference behavior never
14 +/// leaks out of the Engine layer.
15 +struct GenerationParams: Codable, Hashable, Sendable {
16 + var temperature: Float
17 + var topP: Float
18 + var repetitionPenalty: Float?
19 + /// nil = no cap (bounded by the context window).
20 + var maxTokens: Int?
21 + /// nil = fresh entropy per generation.
22 + var seed: UInt64?
23 +
24 + init(
25 + temperature: Float = 0.7,
26 + topP: Float = 0.95,
27 + repetitionPenalty: Float? = nil,
28 + maxTokens: Int? = nil,
29 + seed: UInt64? = nil
30 + ) {
31 + self.temperature = temperature
32 + self.topP = topP
33 + self.repetitionPenalty = repetitionPenalty
34 + self.maxTokens = maxTokens
35 + self.seed = seed
36 + }
37 +}
38 +
39 +extension GenerationParams {
40 + /// Engine-internal mapping to MLX parameters.
41 + func toMLX() -> GenerateParameters {
42 + var p = GenerateParameters()
43 + p.temperature = temperature
44 + p.topP = topP
45 + p.repetitionPenalty = repetitionPenalty
46 + p.maxTokens = maxTokens
47 + p.seed = seed
48 + return p
49 + }
50 +}
added Sources/ZyquoLocal/Engine/InferenceEngine.swift +263 −0
@@ -0,0 +1,263 @@
1 +//
2 +// InferenceEngine.swift
3 +// Zyquo Local
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +
9 +import Foundation
10 +import MLX
11 +import MLXHuggingFace
12 +import MLXLLM
13 +import MLXLMCommon
14 +import Tokenizers
15 +
16 +/// Events streamed by the engine during generation.
17 +enum GenerationEvent: Sendable {
18 + case token(String)
19 + case stats(GenerationStats)
20 + case finished(GenerationFinishReason)
21 +}
22 +
23 +enum GenerationFinishReason: String, Sendable {
24 + case stop
25 + case length
26 + case cancelled
27 +}
28 +
29 +/// Final per-response statistics.
30 +struct GenerationStats: Sendable {
31 + var timeToFirstToken: TimeInterval
32 + var tokensPerSecond: Double
33 + var promptTokenCount: Int
34 + var generationTokenCount: Int
35 + var peakMemoryBytes: Int
36 +}
37 +
38 +/// Engine lifecycle state.
39 +enum EngineState: Sendable, Equatable {
40 + case unloaded
41 + case loading(repoID: String)
42 + case ready(repoID: String)
43 + case generating(repoID: String)
44 +}
45 +
46 +/// Errors surfaced by the engine, with human-readable messages.
47 +enum EngineError: LocalizedError {
48 + case noModelLoaded
49 + case modelDirectoryInvalid(URL)
50 + case loadFailed(String, underlying: String)
51 + case unsupportedArchitecture(String)
52 + case outOfMemory(String)
53 +
54 + var errorDescription: String? {
55 + switch self {
56 + case .noModelLoaded:
57 + "No model is loaded. Load a model from the Library first."
58 + case .modelDirectoryInvalid(let url):
59 + "The model folder is missing required files (config.json, weights, tokenizer): \(url.path)"
60 + case .loadFailed(let repo, let underlying):
61 + "Could not load \(repo): \(underlying)"
62 + case .unsupportedArchitecture(let arch):
63 + "This model's architecture “\(arch)” is not supported by the MLX engine yet."
64 + case .outOfMemory(let repo):
65 + "Not enough memory to load \(repo). Try a smaller quantization (e.g. 4-bit) or a smaller model."
66 + }
67 + }
68 +}
69 +
70 +/// The single owner of all inference. One model loaded at a time (v1).
71 +/// States: unloaded → loading → ready ⇄ generating.
72 +actor InferenceEngine {
73 + private(set) var state: EngineState = .unloaded
74 +
75 + private var container: ModelContainer?
76 + private var loadedModel: LocalModel?
77 + private var session: ChatSession?
78 + private var generationTask: Task<Void, Never>?
79 +
80 + /// Context window of the loaded model (from config.json), defaulting
81 + /// conservatively when absent.
82 + private(set) var contextWindow: Int = 4096
83 +
84 + // MARK: - Load / unload
85 +
86 + /// Loads a model from its local directory, replacing any loaded model.
87 + func load(model: LocalModel) async throws {
88 + await stopGeneration()
89 + unloadInternal()
90 + state = .loading(repoID: model.repoID)
91 + do {
92 + guard FileManager.default.fileExists(
93 + atPath: model.directory.appendingPathComponent("config.json").path)
94 + else {
95 + throw EngineError.modelDirectoryInvalid(model.directory)
96 + }
97 + contextWindow = Self.readContextWindow(directory: model.directory) ?? 4096
98 + let container = try await loadModelContainer(
99 + from: model.directory,
100 + using: #huggingFaceTokenizerLoader()
101 + )
102 + self.container = container
103 + self.loadedModel = model
104 + state = .ready(repoID: model.repoID)
105 + } catch let error as EngineError {
106 + state = .unloaded
107 + throw error
108 + } catch {
109 + state = .unloaded
110 + let text = String(describing: error)
111 + if text.localizedCaseInsensitiveContains("Unsupported model type") {
112 + throw EngineError.unsupportedArchitecture(model.architecture ?? "unknown")
113 + }
114 + if text.localizedCaseInsensitiveContains("memory") {
115 + throw EngineError.outOfMemory(model.repoID)
116 + }
117 + throw EngineError.loadFailed(model.repoID, underlying: error.localizedDescription)
118 + }
119 + }
120 +
121 + /// Unloads the current model and verifiably frees memory.
122 + func unload() async {
123 + await stopGeneration()
124 + unloadInternal()
125 + }
126 +
127 + private func unloadInternal() {
128 + session = nil
129 + container = nil
130 + loadedModel = nil
131 + MemoryAdvisor.reclaimMemory()
132 + state = .unloaded
133 + }
134 +
135 + var currentModel: LocalModel? { loadedModel }
136 +
137 + // MARK: - Sessions
138 +
139 + /// Binds (or rebinds) the engine to a conversation. Rebuilding the session
140 + /// drops the KV cache, so this is only done when switching conversations,
141 + /// or when the system prompt changed.
142 + func startSession(conversation: Conversation) throws {
143 + guard let container else { throw EngineError.noModelLoaded }
144 + session = ChatSession(
145 + container: container,
146 + conversation: conversation,
147 + contextWindow: contextWindow,
148 + params: conversation.params
149 + )
150 + }
151 +
152 + /// Ensures the active session matches the conversation, preserving the
153 + /// KV cache when it does.
154 + func ensureSession(conversation: Conversation) throws {
155 + if session?.conversationID != conversation.id {
156 + try startSession(conversation: conversation)
157 + }
158 + }
159 +
160 + var contextUsage: (used: Int, window: Int)? {
161 + session?.estimatedContextUsage
162 + }
163 +
164 + // MARK: - Generation
165 +
166 + /// Streams a response to `prompt` inside the bound session.
167 + /// Cancellation: cancel the consuming task or call `stopGeneration()` —
168 + /// the underlying generation loop actually stops.
169 + func generate(prompt: String, params: GenerationParams) throws
170 + -> AsyncThrowingStream<GenerationEvent, Error>
171 + {
172 + guard let session else { throw EngineError.noModelLoaded }
173 + guard let repoID = loadedModel?.repoID else { throw EngineError.noModelLoaded }
174 +
175 + let (stream, continuation) = AsyncThrowingStream.makeStream(of: GenerationEvent.self)
176 + state = .generating(repoID: repoID)
177 + MemoryAdvisor.resetPeakMemory()
178 +
179 + let task = Task {
180 + let start = ContinuousClock.now
181 + var firstTokenTime: TimeInterval?
182 + var response = ""
183 + var finished: GenerationFinishReason = .cancelled
184 + do {
185 + for try await generation in session.stream(prompt: prompt, params: params) {
186 + if Task.isCancelled { break }
187 + switch generation {
188 + case .chunk(let text):
189 + if firstTokenTime == nil {
190 + firstTokenTime = Self.seconds(since: start)
191 + }
192 + response += text
193 + continuation.yield(.token(text))
194 + case .info(let info):
195 + let stats = GenerationStats(
196 + timeToFirstToken: firstTokenTime ?? info.promptTime,
197 + tokensPerSecond: info.tokensPerSecond,
198 + promptTokenCount: info.promptTokenCount,
199 + generationTokenCount: info.generationTokenCount,
200 + peakMemoryBytes: MemoryAdvisor.peakMemoryBytes
201 + )
202 + finished = switch info.stopReason {
203 + case .stop: .stop
204 + case .length: .length
205 + case .cancelled: .cancelled
206 + }
207 + continuation.yield(.stats(stats))
208 + case .toolCall:
209 + break
210 + }
211 + }
212 + session.noteResponse(response)
213 + continuation.yield(.finished(Task.isCancelled ? .cancelled : finished))
214 + continuation.finish()
215 + } catch {
216 + continuation.finish(throwing: error)
217 + }
218 + self.finishGeneration()
219 + }
220 + generationTask = task
221 + continuation.onTermination = { termination in
222 + if case .cancelled = termination { task.cancel() }
223 + }
224 + return stream
225 + }
226 +
227 + /// Stops any in-flight generation and waits for it to wind down.
228 + func stopGeneration() async {
229 + generationTask?.cancel()
230 + _ = await generationTask?.value
231 + generationTask = nil
232 + }
233 +
234 + private func finishGeneration() {
235 + if let repoID = loadedModel?.repoID {
236 + state = .ready(repoID: repoID)
237 + }
238 + generationTask = nil
239 + }
240 +
241 + // MARK: - Helpers
242 +
243 + private static func seconds(since start: ContinuousClock.Instant) -> TimeInterval {
244 + let duration = start.duration(to: .now)
245 + return Double(duration.components.seconds)
246 + + Double(duration.components.attoseconds) * 1e-18
247 + }
248 +
249 + /// Reads max_position_embeddings from config.json.
250 + static func readContextWindow(directory: URL) -> Int? {
251 + let url = directory.appendingPathComponent("config.json")
252 + guard let data = try? Data(contentsOf: url),
253 + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
254 + else { return nil }
255 + if let value = json["max_position_embeddings"] as? Int { return value }
256 + if let text = json["text_config"] as? [String: Any],
257 + let value = text["max_position_embeddings"] as? Int
258 + {
259 + return value
260 + }
261 + return nil
262 + }
263 +}
added Sources/ZyquoLocal/Engine/MemoryAdvisor.swift +62 −0
@@ -0,0 +1,62 @@
1 +//
2 +// MemoryAdvisor.swift
3 +// Zyquo Local
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +
9 +import Foundation
10 +import MLX
11 +
12 +/// RAM estimation and fits/tight/won't-fit verdicts for THIS Mac.
13 +/// Thresholds follow docs/MLX-RESEARCH.md §7: the macOS GPU-wired ceiling is
14 +/// ~70–75 % of unified memory, so weights ≤60 % is comfortable, ≤75 % tight.
15 +enum MemoryAdvisor {
16 + enum Verdict: String, Codable, Sendable {
17 + case fits
18 + case tight
19 + case tooLarge
20 +
21 + var label: String {
22 + switch self {
23 + case .fits: "Fits"
24 + case .tight: "Tight"
25 + case .tooLarge: "Too large"
26 + }
27 + }
28 + }
29 +
30 + /// Physical unified memory of this Mac in bytes.
31 + static var physicalMemoryBytes: UInt64 { HardwareGate.physicalMemory }
32 +
33 + /// Estimated RAM needed to run a model: weights + KV cache + overhead.
34 + /// weights×1.2 covers activations/prefill working set; +1.5 GB covers a
35 + /// useful KV cache at moderate context.
36 + static func estimatedRuntimeBytes(weightsBytes: Int64) -> Int64 {
37 + Int64(Double(weightsBytes) * 1.2) + 1_500_000_000
38 + }
39 +
40 + /// Verdict for a model of the given on-disk weights size.
41 + static func verdict(weightsBytes: Int64) -> Verdict {
42 + let ram = Double(physicalMemoryBytes)
43 + let weights = Double(weightsBytes)
44 + if weights <= ram * 0.60 { return .fits }
45 + if weights <= ram * 0.75 { return .tight }
46 + return .tooLarge
47 + }
48 +
49 + /// Verdict for running two models side by side (Compare mode gate).
50 + static func verdict(combinedWeightsBytes: Int64) -> Verdict {
51 + verdict(weightsBytes: combinedWeightsBytes)
52 + }
53 +
54 + /// Live MLX memory snapshot (bytes actively used by the loaded model).
55 + static var activeMemoryBytes: Int { MLX.Memory.activeMemory }
56 + static var peakMemoryBytes: Int { MLX.Memory.peakMemory }
57 +
58 + static func resetPeakMemory() { MLX.GPU.resetPeakMemory() }
59 +
60 + /// Returns cached MLX buffers to the OS — called after unloading a model.
61 + static func reclaimMemory() { MLX.Memory.clearCache() }
62 +}
added Sources/ZyquoLocal/Models/Conversation.swift +45 −0
@@ -0,0 +1,45 @@
1 +//
2 +// Conversation.swift
3 +// Zyquo Local
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +
9 +import Foundation
10 +
11 +/// A persisted chat conversation. Remembers its model and generation params.
12 +struct Conversation: Identifiable, Codable, Hashable, Sendable {
13 + var id: UUID
14 + var title: String
15 + var messages: [Message]
16 + /// Repo ID of the model this conversation uses (e.g. "mlx-community/Qwen3-8B-4bit").
17 + var modelID: String?
18 + var systemPrompt: String?
19 + var params: GenerationParams
20 + var pinned: Bool
21 + var createdAt: Date
22 + var updatedAt: Date
23 +
24 + init(
25 + id: UUID = UUID(),
26 + title: String = "New Chat",
27 + messages: [Message] = [],
28 + modelID: String? = nil,
29 + systemPrompt: String? = nil,
30 + params: GenerationParams = GenerationParams(),
31 + pinned: Bool = false,
32 + createdAt: Date = Date(),
33 + updatedAt: Date = Date()
34 + ) {
35 + self.id = id
36 + self.title = title
37 + self.messages = messages
38 + self.modelID = modelID
39 + self.systemPrompt = systemPrompt
40 + self.params = params
41 + self.pinned = pinned
42 + self.createdAt = createdAt
43 + self.updatedAt = updatedAt
44 + }
45 +}
added Sources/ZyquoLocal/Models/DownloadTask.swift +49 −0
@@ -0,0 +1,49 @@
1 +//
2 +// DownloadTask.swift
3 +// Zyquo Local
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +
9 +import Foundation
10 +
11 +/// State of one model download managed by `DownloadManager` (Phase 3).
12 +struct DownloadTask: Identifiable, Codable, Hashable, Sendable {
13 + enum State: String, Codable, Sendable {
14 + case queued
15 + case downloading
16 + case paused
17 + case verifying
18 + case completed
19 + case failed
20 + case cancelled
21 + }
22 +
23 + /// Progress of a single file inside the download.
24 + struct FileProgress: Codable, Hashable, Sendable {
25 + var path: String
26 + var totalBytes: Int64
27 + var receivedBytes: Int64
28 + /// LFS sha256 (from x-linked-etag) when available, for integrity checks.
29 + var sha256: String?
30 + var completed: Bool
31 + }
32 +
33 + var repoID: String
34 + var state: State
35 + var files: [FileProgress]
36 + var errorDescription: String?
37 + var startedAt: Date
38 +
39 + var id: String { repoID }
40 +
41 + var totalBytes: Int64 { files.reduce(0) { $0 + $1.totalBytes } }
42 + var receivedBytes: Int64 { files.reduce(0) { $0 + $1.receivedBytes } }
43 +
44 + var fractionCompleted: Double {
45 + let total = totalBytes
46 + guard total > 0 else { return 0 }
47 + return Double(receivedBytes) / Double(total)
48 + }
49 +}
added Sources/ZyquoLocal/Models/LocalModel.swift +39 −0
@@ -0,0 +1,39 @@
1 +//
2 +// LocalModel.swift
3 +// Zyquo Local
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +
9 +import Foundation
10 +
11 +/// A model installed on disk under the Models folder.
12 +/// The one and only term for this concept is `LocalModel`.
13 +struct LocalModel: Identifiable, Codable, Hashable, Sendable {
14 + /// Full repo ID, e.g. "mlx-community/Qwen3-8B-4bit". Also the identity.
15 + var repoID: String
16 + /// Absolute directory containing config.json + weights + tokenizer.
17 + var directory: URL
18 + /// Total size on disk in bytes.
19 + var sizeBytes: Int64
20 + /// `model_type` from config.json (architecture).
21 + var architecture: String?
22 + /// Quantization label parsed from the repo name ("4bit", "8bit", "bf16"…).
23 + var quantization: String?
24 + /// Context window (max_position_embeddings) from config.json.
25 + var contextWindow: Int?
26 + var lastUsed: Date?
27 + /// Per-model default generation parameters (overrides app defaults).
28 + var defaultParams: GenerationParams?
29 +
30 + var id: String { repoID }
31 +
32 + var organization: String {
33 + repoID.split(separator: "/").first.map(String.init) ?? ""
34 + }
35 +
36 + var name: String {
37 + repoID.split(separator: "/").last.map(String.init) ?? repoID
38 + }
39 +}
added Sources/ZyquoLocal/Models/Message.swift +54 −0
@@ -0,0 +1,54 @@
1 +//
2 +// Message.swift
3 +// Zyquo Local
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +
9 +import Foundation
10 +
11 +/// A single chat message. `thinking` holds the extracted `<think>…</think>`
12 +/// content for reasoning models; `stats` is present on assistant messages.
13 +struct Message: Identifiable, Codable, Hashable, Sendable {
14 + enum Role: String, Codable, Sendable {
15 + case system
16 + case user
17 + case assistant
18 + }
19 +
20 + var id: UUID
21 + var role: Role
22 + var content: String
23 + var thinking: String?
24 + var stats: MessageStats?
25 + var date: Date
26 +
27 + init(
28 + id: UUID = UUID(),
29 + role: Role,
30 + content: String,
31 + thinking: String? = nil,
32 + stats: MessageStats? = nil,
33 + date: Date = Date()
34 + ) {
35 + self.id = id
36 + self.role = role
37 + self.content = content
38 + self.thinking = thinking
39 + self.stats = stats
40 + self.date = date
41 + }
42 +}
43 +
44 +/// Per-response performance statistics (first-class per the spec).
45 +struct MessageStats: Codable, Hashable, Sendable {
46 + /// Seconds from send to the first streamed token.
47 + var timeToFirstToken: TimeInterval
48 + /// Generation-phase tokens per second.
49 + var tokensPerSecond: Double
50 + var promptTokenCount: Int
51 + var generationTokenCount: Int
52 + /// Peak MLX GPU memory during the response, in bytes.
53 + var peakMemoryBytes: Int
54 +}
added Sources/ZyquoLocal/Models/Persona.swift +33 −0
@@ -0,0 +1,33 @@
1 +//
2 +// Persona.swift
3 +// Zyquo Local
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +
9 +import Foundation
10 +
11 +/// A reusable persona: system prompt + preferred model + parameters.
12 +struct Persona: Identifiable, Codable, Hashable, Sendable {
13 + var id: UUID
14 + var name: String
15 + var systemPrompt: String
16 + /// Preferred model repo ID; nil = whatever is loaded.
17 + var preferredModelID: String?
18 + var params: GenerationParams?
19 +
20 + init(
21 + id: UUID = UUID(),
22 + name: String,
23 + systemPrompt: String,
24 + preferredModelID: String? = nil,
25 + params: GenerationParams? = nil
26 + ) {
27 + self.id = id
28 + self.name = name
29 + self.systemPrompt = systemPrompt
30 + self.preferredModelID = preferredModelID
31 + self.params = params
32 + }
33 +}
modified docs/PLAN.md +15 −1
@@ -48,7 +48,21 @@ via swift-transformers. 30-model catalog live-verified with real sizes.
48 48 compiles under the pinned recipe. Bundle carries mlx-swift_Cmlx.bundle (metallib),
49 49 Hub + Crypto resource bundles. `codesign -dv`: com.zyquo.local, arm64, adhoc.
50 50 Executable dispatches --poc and exits 64 as designed.
51 ## Phase 2 — Architecture + inference PoC — pending
51 +## Phase 2 — Architecture + inference PoC — ✅ DONE (2026-07-30)
52 +
53 +- [x] Models/: Conversation, Message (+MessageStats), LocalModel, DownloadTask, Persona
54 +- [x] Engine/GenerationParams.swift (Codable app params → GenerateParameters at engine boundary)
55 +- [x] Engine/MemoryAdvisor.swift (verdicts 60 %/75 % of hw.memsize, live MLX.Memory readouts)
56 +- [x] Engine/ChatSession.swift (history → chat template via MLX session, oldest-turn truncation keeping system prompt, KV reuse, per-call params without KV loss)
57 +- [x] Engine/InferenceEngine.swift (actor; unloaded→loading→ready⇄generating; AsyncThrowingStream<GenerationEvent>; cancellation wired via task cancel + onTermination; unload → clearCache)
58 +- [x] PoCRunner: `ZyquoLocal --poc <dir> "<prompt>"`
59 +- [x] PHASE GATE: Qwen3-0.6B-4bit loaded from local dir, streamed to stdout —
60 + 130.4 tok/s, TTFT 1.59 s, 329 tokens, peak 388 MB, stop reason `stop`.
61 +
62 +**Checkpoint:** Zero warnings. Engine layer fully owns inference; stats are
63 +first-class; `<think>` content streams through (reasoning display feeds on it
64 +in Phase 6). Multi-turn KV reuse + cancellation get their formal end-to-end
65 +tests in the Phase 7 harness.
52 66 ## Phase 3 — Hub browse & download — pending
53 67 ## Phase 4 — Design system & UI spec — pending
54 68 ## Phase 5 — App icon — pending
55 69