@tanstack-ai-browser/afm 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/Package.swift +33 -0
  3. package/README.md +62 -2
  4. package/dist/adapter.d.ts +96 -0
  5. package/dist/adapter.d.ts.map +1 -0
  6. package/dist/adapter.js +490 -0
  7. package/dist/electron.d.ts +22 -0
  8. package/dist/electron.d.ts.map +1 -0
  9. package/dist/electron.js +131 -0
  10. package/dist/index.d.ts +17 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +12 -0
  13. package/dist/messages.d.ts +12 -0
  14. package/dist/messages.d.ts.map +1 -0
  15. package/dist/messages.js +29 -0
  16. package/dist/model-meta.d.ts +23 -0
  17. package/dist/model-meta.d.ts.map +1 -0
  18. package/dist/model-meta.js +20 -0
  19. package/dist/native.d.ts +163 -0
  20. package/dist/native.d.ts.map +1 -0
  21. package/dist/native.js +21 -0
  22. package/dist/schema.d.ts +17 -0
  23. package/dist/schema.d.ts.map +1 -0
  24. package/dist/schema.js +133 -0
  25. package/electron/AfmNode.swift +81 -0
  26. package/electron/afm.c +94 -0
  27. package/electron/afm.node +0 -0
  28. package/electron/afm.node.sha256 +1 -0
  29. package/electron/build.mjs +54 -0
  30. package/electron/preload.cjs +35 -0
  31. package/electron/sources-hash.mjs +40 -0
  32. package/ios/Sources/AfmCore/Bridge.swift +315 -0
  33. package/ios/Sources/AfmCore/Conversation.swift +49 -0
  34. package/ios/Sources/AfmCore/Schema.swift +90 -0
  35. package/ios/Sources/AfmPlugin/AfmPlugin.swift +89 -0
  36. package/package.json +98 -4
  37. package/src/adapter.test.ts +380 -0
  38. package/src/adapter.ts +574 -0
  39. package/src/electron.ts +180 -0
  40. package/src/index.ts +27 -0
  41. package/src/messages.ts +37 -0
  42. package/src/model-meta.ts +37 -0
  43. package/src/native-build.test.ts +13 -0
  44. package/src/native.ts +119 -0
  45. package/src/schema.test.ts +146 -0
  46. package/src/schema.ts +153 -0
@@ -0,0 +1,54 @@
1
+ // Builds the Node-API addon serveAfm() loads, electron/afm.node: the Swift bridge
2
+ // (ios/Sources/AfmCore, which the Capacitor plugin uses too), AfmNode.swift (its C
3
+ // functions) and afm.c (Node-API), in one Mach-O bundle for Apple silicon Macs.
4
+ // Node-API is the same in every Node and Electron, so one build loads in all of them.
5
+ // Needs Xcode on macOS.
6
+ //
7
+ // node electron/build.mjs
8
+ import { execFileSync } from "node:child_process"
9
+ import { readdirSync, writeFileSync } from "node:fs"
10
+ import { createRequire } from "node:module"
11
+ import { dirname, join } from "node:path"
12
+ import { fileURLToPath } from "node:url"
13
+
14
+ const here = dirname(fileURLToPath(import.meta.url))
15
+ const require = createRequire(import.meta.url)
16
+ const core = join(here, "..", "ios", "Sources", "AfmCore")
17
+ const headers = join(dirname(require.resolve("node-api-headers/package.json")), "include")
18
+ // The oldest macOS the addon loads on; Apple's model needs macOS 26 (checked at run time).
19
+ const target = "arm64-apple-macos13.0"
20
+
21
+ const run = (command, args) => execFileSync("xcrun", [command, ...args], { stdio: "inherit" })
22
+
23
+ const object = join(here, "afm.o")
24
+ run("clang", ["-c", "-O2", "-target", target, "-I", headers, join(here, "afm.c"), "-o", object])
25
+ run("swiftc", [
26
+ "-O",
27
+ "-emit-library",
28
+ "-module-name",
29
+ "AfmNode",
30
+ "-target",
31
+ target,
32
+ ...readdirSync(core)
33
+ .filter((file) => file.endsWith(".swift"))
34
+ .map((file) => join(core, file)),
35
+ join(here, "AfmNode.swift"),
36
+ object,
37
+ "-o",
38
+ join(here, "afm.node"),
39
+ // Node-API comes from the process that loads the addon.
40
+ "-Xlinker",
41
+ "-undefined",
42
+ "-Xlinker",
43
+ "dynamic_lookup",
44
+ // macOS before 26 has no FoundationModels: the addon still loads there.
45
+ "-Xlinker",
46
+ "-weak_framework",
47
+ "-Xlinker",
48
+ "FoundationModels"
49
+ ])
50
+ execFileSync("rm", ["-f", object])
51
+ // Which sources this addon is from: committed with it, and checked by a test.
52
+ const { hashFile, sourcesHash } = await import("./sources-hash.mjs")
53
+ writeFileSync(hashFile, `${sourcesHash()}\n`)
54
+ console.log(`Built ${join(here, "afm.node")}`)
@@ -0,0 +1,35 @@
1
+ // The preload serveAfm() adds to the app's pages: Apple's on-device model as
2
+ // window.afm (the AfmNative interface), which afmText() finds on its own. The main
3
+ // process runs the model; this only talks to it. CommonJS needing nothing but
4
+ // Electron, as a sandboxed preload must.
5
+ "use strict"
6
+
7
+ const { contextBridge, ipcRenderer } = require("electron")
8
+
9
+ const call =
10
+ (method) =>
11
+ (options = {}) =>
12
+ ipcRenderer.invoke("afm:call", method, options)
13
+
14
+ const afm = {
15
+ availability: call("availability"),
16
+ createSession: call("createSession"),
17
+ prewarm: call("prewarm"),
18
+ respond: call("respond"),
19
+ submitToolResult: call("submitToolResult"),
20
+ cancel: call("cancel"),
21
+ deleteSession: call("deleteSession"),
22
+ addListener(_name, listener) {
23
+ const forward = (_event, event) => listener(event)
24
+ ipcRenderer.on("afm:event", forward)
25
+ return Promise.resolve({
26
+ remove: () => {
27
+ ipcRenderer.removeListener("afm:event", forward)
28
+ return Promise.resolve()
29
+ }
30
+ })
31
+ }
32
+ }
33
+
34
+ if (process.contextIsolated) contextBridge.exposeInMainWorld("afm", afm)
35
+ else globalThis.afm = afm
@@ -0,0 +1,40 @@
1
+ // The addon's sources, hashed: electron/afm.node is committed (so packages build
2
+ // without Xcode), and electron/afm.node.sha256 records which sources it was built
3
+ // from. build.mjs writes it; a test fails when the sources no longer match, so a
4
+ // change to the Swift or C can't ship with a stale addon.
5
+ import { createHash } from "node:crypto"
6
+ import { readdirSync, readFileSync } from "node:fs"
7
+ import { dirname, join, relative } from "node:path"
8
+ import { fileURLToPath } from "node:url"
9
+
10
+ const here = dirname(fileURLToPath(import.meta.url))
11
+ const root = join(here, "..")
12
+
13
+ /** The files afm.node is built from, in a fixed order. */
14
+ export function sourceFiles() {
15
+ const core = join(root, "ios", "Sources", "AfmCore")
16
+ return [
17
+ ...readdirSync(core)
18
+ .filter((file) => file.endsWith(".swift"))
19
+ .sort()
20
+ .map((file) => join(core, file)),
21
+ join(here, "AfmNode.swift"),
22
+ join(here, "afm.c"),
23
+ join(here, "build.mjs")
24
+ ]
25
+ }
26
+
27
+ /** A SHA-256 of the sources: each file's path (from the package) and contents. */
28
+ export function sourcesHash() {
29
+ const hash = createHash("sha256")
30
+ for (const file of sourceFiles()) {
31
+ hash.update(relative(root, file))
32
+ hash.update("\0")
33
+ hash.update(readFileSync(file))
34
+ hash.update("\0")
35
+ }
36
+ return hash.digest("hex")
37
+ }
38
+
39
+ /** Where the hash of the sources the committed addon was built from is kept. */
40
+ export const hashFile = join(here, "afm.node.sha256")
@@ -0,0 +1,315 @@
1
+ import Foundation
2
+ import FoundationModels
3
+
4
+ /// Apple's on-device model for a JavaScript host: the Capacitor plugin on iOS, the
5
+ /// Electron addon on macOS. Calls and events are JSON-shaped dictionaries, so each host
6
+ /// only passes them through.
7
+ ///
8
+ /// Tools are the framework's own: a session calls them in the middle of a response.
9
+ /// Each tool here stands in for one the JavaScript side runs. When the model calls
10
+ /// tools, the bridge sends a `toolCalls` event once every call of the model's batch
11
+ /// has started (the session's transcript lists the batch), and each call waits for
12
+ /// `submitToolResult`. The response then goes on where it was: the session keeps
13
+ /// everything it has read, and nothing is sent to it again.
14
+ ///
15
+ /// Events, each with the `sessionId` and the `turn` of the `respond` call:
16
+ /// - `{ type: "delta", text }`: more of the response.
17
+ /// - `{ type: "toolCalls", calls: [{ id, name, arguments }] }`: `arguments` in JSON.
18
+ /// - `{ type: "done", text, usage }`: the response, and its tokens (iOS/macOS 27):
19
+ /// `inputTokens` and `outputTokens` over every pass of the model (a tool call's
20
+ /// too), and `cachedTokens` only for a session without tools (see `usage()`).
21
+ /// - `{ type: "error", code, message }`.
22
+ @available(iOS 26.0, macOS 26.0, *)
23
+ public final class AfmBridge: @unchecked Sendable {
24
+ public typealias Emit = @Sendable ([String: Any]) -> Void
25
+
26
+ private let emit: Emit
27
+ private let lock = NSLock()
28
+ private var sessions: [String: BridgeSession] = [:]
29
+
30
+ public init(emit: @escaping Emit) {
31
+ self.emit = emit
32
+ }
33
+
34
+ /// Whether the model can run here, why not, and the tokens a session holds.
35
+ public static func availability() -> [String: Any] {
36
+ let model = SystemLanguageModel.default
37
+ var result: [String: Any]
38
+ switch model.availability {
39
+ case .available:
40
+ result = ["available": true]
41
+ case .unavailable(let reason):
42
+ let name: String
43
+ switch reason {
44
+ case .deviceNotEligible: name = "deviceNotEligible"
45
+ case .appleIntelligenceNotEnabled: name = "appleIntelligenceNotEnabled"
46
+ case .modelNotReady: name = "modelNotReady"
47
+ @unknown default: name = "unavailable"
48
+ }
49
+ result = ["available": false, "reason": name]
50
+ }
51
+ if #available(iOS 26.4, macOS 26.4, *) {
52
+ result["contextSize"] = model.contextSize
53
+ }
54
+ return result
55
+ }
56
+
57
+ /// Starts a session: `sessionId`, `instructions`, `tools` ([{ name, description,
58
+ /// parameters }]) and `messages`, the conversation it starts from. A session with
59
+ /// the same id is ended first.
60
+ public func createSession(_ options: [String: Any]) throws {
61
+ let id = try Self.string(options, "sessionId")
62
+ deleteSession(id)
63
+ let state = BridgeSession(id: id, emit: emit)
64
+ let tools = try (options["tools"] as? [Any] ?? []).map { item -> BridgedTool in
65
+ let spec = try Schema.object(item, "tool")
66
+ return BridgedTool(
67
+ name: spec["name"] as? String ?? "",
68
+ description: spec["description"] as? String ?? "",
69
+ parameters: try Schema.generationSchema(spec["parameters"]),
70
+ state: state
71
+ )
72
+ }
73
+ let transcript = try Conversation.transcript(
74
+ instructions: options["instructions"] as? String,
75
+ tools: tools.map { Transcript.ToolDefinition(tool: $0) },
76
+ messages: options["messages"] as? [Any] ?? []
77
+ )
78
+ state.session = LanguageModelSession(model: .default, tools: tools, transcript: transcript)
79
+ state.hasTools = !tools.isEmpty
80
+ lock.withLock { sessions[id] = state }
81
+ }
82
+
83
+ /// Loads the session's model ahead of its first prompt.
84
+ public func prewarm(_ options: [String: Any]) throws {
85
+ try session(options).session.prewarm()
86
+ }
87
+
88
+ /// Prompts a session (`sessionId`, `prompt`, `turn`, `options`: `temperature`,
89
+ /// `maximumResponseTokens`). The response comes as events. With a `schema` (as
90
+ /// tools' parameters take it), the answer is held to it, and `done` has it in JSON.
91
+ public func respond(_ options: [String: Any]) throws {
92
+ let state = try session(options)
93
+ let settings = options["options"] as? [String: Any] ?? [:]
94
+ var generation = GenerationOptions(
95
+ temperature: (settings["temperature"] as? NSNumber)?.doubleValue,
96
+ maximumResponseTokens: (settings["maximumResponseTokens"] as? NSNumber)?.intValue
97
+ )
98
+ // Whether the model may call tools ("allowed"), must ("required") or
99
+ // mustn't ("disallowed"): iOS/macOS 27. Left out, the framework decides.
100
+ if #available(iOS 27.0, macOS 27.0, *), let mode = settings["toolCallingMode"] as? String {
101
+ switch mode {
102
+ case "required": generation.toolCallingMode = .required
103
+ case "disallowed": generation.toolCallingMode = .disallowed
104
+ default: generation.toolCallingMode = .allowed
105
+ }
106
+ }
107
+ let schema = try options["schema"].map(Schema.generationSchema)
108
+ try state.respond(prompt: options["prompt"] as? String ?? "", turn: options["turn"] as? Int ?? 0, options: generation, schema: schema)
109
+ }
110
+
111
+ /// A tool's result (`sessionId`, `callId`, `output`): the waiting call returns it.
112
+ public func submitToolResult(_ options: [String: Any]) throws {
113
+ try session(options).resolve(callId: try Self.string(options, "callId"), output: options["output"] as? String ?? "")
114
+ }
115
+
116
+ /// Stops a session's response; the session stays.
117
+ public func cancel(_ options: [String: Any]) throws {
118
+ try session(options).cancel()
119
+ }
120
+
121
+ /// Ends a session.
122
+ public func deleteSession(_ id: String) {
123
+ lock.withLock { sessions.removeValue(forKey: id) }?.cancel()
124
+ }
125
+
126
+ private func session(_ options: [String: Any]) throws -> BridgeSession {
127
+ let id = try Self.string(options, "sessionId")
128
+ guard let state = lock.withLock({ sessions[id] }) else {
129
+ throw Schema.Unsupported(description: "No session \(id)")
130
+ }
131
+ return state
132
+ }
133
+
134
+ private static func string(_ options: [String: Any], _ key: String) throws -> String {
135
+ guard let value = options[key] as? String else { throw Schema.Unsupported(description: "Missing \(key)") }
136
+ return value
137
+ }
138
+ }
139
+
140
+ /// A session and its response in progress, with the tool calls waiting for results.
141
+ @available(iOS 26.0, macOS 26.0, *)
142
+ final class BridgeSession: @unchecked Sendable {
143
+ let id: String
144
+ var session: LanguageModelSession!
145
+ var hasTools = false
146
+
147
+ private let emit: AfmBridge.Emit
148
+ private let lock = NSLock()
149
+ private var task: Task<Void, Never>?
150
+ private var turn = 0
151
+ private var waiting: [Call] = []
152
+ private var answered: Set<String> = []
153
+
154
+ private struct Call {
155
+ let id: String
156
+ let name: String
157
+ let arguments: String
158
+ var reported: Bool
159
+ let continuation: CheckedContinuation<String, Error>
160
+ }
161
+
162
+ init(id: String, emit: @escaping AfmBridge.Emit) {
163
+ self.id = id
164
+ self.emit = emit
165
+ }
166
+
167
+ func respond(prompt: String, turn: Int, options: GenerationOptions, schema: GenerationSchema?) throws {
168
+ try lock.withLock {
169
+ guard task == nil else { throw Schema.Unsupported(description: "Session \(id) is responding") }
170
+ self.turn = turn
171
+ answered = []
172
+ task = Task { [self] in await run(prompt, options, schema) }
173
+ }
174
+ }
175
+
176
+ private func run(_ prompt: String, _ options: GenerationOptions, _ schema: GenerationSchema?) async {
177
+ let before = usage()
178
+ var text = ""
179
+ do {
180
+ if let schema {
181
+ text = try await session.respond(to: prompt, schema: schema, options: options).content.jsonString
182
+ } else {
183
+ for try await snapshot in session.streamResponse(to: prompt, options: options) {
184
+ let content = snapshot.content
185
+ let delta = content.hasPrefix(text) ? String(content.dropFirst(text.count)) : content
186
+ text = content
187
+ if !delta.isEmpty { send(["type": "delta", "text": delta]) }
188
+ }
189
+ }
190
+ // A stopped response's stream may just end.
191
+ try Task.checkCancellation()
192
+ var done: [String: Any] = ["type": "done", "text": text]
193
+ if let before, let after = usage() {
194
+ var usage: [String: Any] = [
195
+ "inputTokens": after.input - before.input,
196
+ "outputTokens": after.output - before.output
197
+ ]
198
+ if !hasTools { usage["cachedTokens"] = after.cached - before.cached }
199
+ done["usage"] = usage
200
+ }
201
+ send(done)
202
+ } catch is CancellationError {
203
+ send(["type": "error", "code": "cancelled", "message": "The response was stopped"])
204
+ } catch {
205
+ send(["type": "error", "code": Self.code(error), "message": String(describing: error)])
206
+ }
207
+ lock.withLock { task = nil }
208
+ }
209
+
210
+ /// A tool call: waits for its result, reporting the model's batch once it's whole.
211
+ func call(name: String, arguments: GeneratedContent) async throws -> String {
212
+ let json = arguments.jsonString
213
+ return try await withTaskCancellationHandler {
214
+ try await withCheckedThrowingContinuation { continuation in
215
+ lock.withLock {
216
+ // Cancelled before this registers: the cancellation handler has
217
+ // already run (under this lock), so it would never see this call.
218
+ if Task.isCancelled {
219
+ continuation.resume(throwing: CancellationError())
220
+ return
221
+ }
222
+ // The model's batch of calls, from the transcript: its ids, and its size.
223
+ var batch: [Transcript.ToolCall] = []
224
+ if case .toolCalls(let calls) = session.transcript.last { batch = Array(calls) }
225
+ let taken = Set(waiting.map(\.id)).union(answered)
226
+ let match = batch.first { !taken.contains($0.id) && $0.toolName == name && $0.arguments.jsonString == json }
227
+ ?? batch.first { !taken.contains($0.id) && $0.toolName == name }
228
+ waiting.append(Call(id: match?.id ?? UUID().uuidString, name: name, arguments: json, reported: false, continuation: continuation))
229
+ // Every call of the batch has started (or the batch can't be read).
230
+ let started = Set(waiting.map(\.id)).union(answered)
231
+ if match == nil || batch.allSatisfy({ started.contains($0.id) }) { reportWaiting() }
232
+ }
233
+ }
234
+ } onCancel: {
235
+ self.failWaiting(CancellationError())
236
+ }
237
+ }
238
+
239
+ /// Sends the waiting calls not sent yet. Called holding the lock.
240
+ private func reportWaiting() {
241
+ let calls = waiting.indices.filter { !waiting[$0].reported }
242
+ guard !calls.isEmpty else { return }
243
+ for index in calls { waiting[index].reported = true }
244
+ send(["type": "toolCalls", "calls": calls.map { ["id": waiting[$0].id, "name": waiting[$0].name, "arguments": waiting[$0].arguments] }])
245
+ }
246
+
247
+ func resolve(callId: String, output: String) throws {
248
+ let continuation = try lock.withLock { () -> CheckedContinuation<String, Error> in
249
+ guard let index = waiting.firstIndex(where: { $0.id == callId }) else {
250
+ throw Schema.Unsupported(description: "No tool call \(callId) is waiting")
251
+ }
252
+ answered.insert(callId)
253
+ return waiting.remove(at: index).continuation
254
+ }
255
+ continuation.resume(returning: output)
256
+ }
257
+
258
+ func cancel() {
259
+ let task = lock.withLock { self.task }
260
+ task?.cancel()
261
+ failWaiting(CancellationError())
262
+ }
263
+
264
+ private func failWaiting(_ error: Error) {
265
+ let calls = lock.withLock { () -> [Call] in
266
+ defer { waiting = [] }
267
+ return waiting
268
+ }
269
+ for call in calls { call.continuation.resume(throwing: error) }
270
+ }
271
+
272
+ private func send(_ event: [String: Any]) {
273
+ var event = event
274
+ event["sessionId"] = id
275
+ event["turn"] = turn
276
+ emit(event)
277
+ }
278
+
279
+ /// The session's tokens so far (iOS/macOS 27), over every pass of the model:
280
+ /// `respond`'s usage is the difference, a tool call's pass included, which the
281
+ /// response's own usage leaves out.
282
+ ///
283
+ /// The cached count is right for a session without tools: 0 for a cold
284
+ /// prompt, the new message's tokens fewer for a follow-up, and 0 again once
285
+ /// the framework has dropped the cache (a few seconds idle). With tools, the
286
+ /// framework counts every token but one as cached, whether the cache was
287
+ /// used (TTFT 1.5 s) or had been dropped (5.9 s), so it isn't reported.
288
+ private func usage() -> (input: Int, cached: Int, output: Int)? {
289
+ guard #available(iOS 27.0, macOS 27.0, *) else { return nil }
290
+ let usage = session.usage
291
+ return (usage.input.totalTokenCount, usage.input.cachedTokenCount, usage.output.totalTokenCount)
292
+ }
293
+
294
+ /// An error's case name (`exceededContextWindowSize`, `guardrailViolation`, …).
295
+ static func code(_ error: Error) -> String {
296
+ let text = String(describing: error)
297
+ return String(text.prefix { $0 != "(" && $0 != ":" && $0 != " " })
298
+ }
299
+ }
300
+
301
+ /// A tool the JavaScript side runs, as the framework sees it.
302
+ @available(iOS 26.0, macOS 26.0, *)
303
+ struct BridgedTool: Tool {
304
+ let name: String
305
+ let description: String
306
+ let parameters: GenerationSchema
307
+ /// Weak: the session's `LanguageModelSession` holds its tools, so a strong
308
+ /// reference back would keep a deleted session alive.
309
+ weak let state: BridgeSession?
310
+
311
+ func call(arguments: GeneratedContent) async throws -> String {
312
+ guard let state else { throw CancellationError() }
313
+ return try await state.call(name: name, arguments: arguments)
314
+ }
315
+ }
@@ -0,0 +1,49 @@
1
+ import Foundation
2
+ import FoundationModels
3
+
4
+ /// A session's starting transcript: its instructions, its tools' definitions, and the
5
+ /// conversation before the prompt, as the adapter sends it (src/messages.ts):
6
+ ///
7
+ /// - `{ role: "user", text }`
8
+ /// - `{ role: "assistant", text, toolCalls: [{ id, name, arguments }] }`, `arguments` in JSON
9
+ /// - `{ role: "tool", toolCallId, name, text }`
10
+ @available(iOS 26.0, macOS 26.0, *)
11
+ enum Conversation {
12
+ static func transcript(instructions: String?, tools: [Transcript.ToolDefinition], messages: [Any]) throws -> Transcript {
13
+ var entries: [Transcript.Entry] = []
14
+ if (instructions?.isEmpty == false) || !tools.isEmpty {
15
+ entries.append(.instructions(Transcript.Instructions(segments: segments(instructions ?? ""), toolDefinitions: tools)))
16
+ }
17
+ for item in messages {
18
+ let message = try Schema.object(item, "message")
19
+ let text = message["text"] as? String ?? ""
20
+ switch message["role"] as? String {
21
+ case "user":
22
+ entries.append(.prompt(Transcript.Prompt(segments: segments(text))))
23
+ case "assistant":
24
+ if !text.isEmpty {
25
+ entries.append(.response(Transcript.Response(assetIDs: [], segments: segments(text))))
26
+ }
27
+ let calls = try (message["toolCalls"] as? [Any] ?? []).map { item -> Transcript.ToolCall in
28
+ let call = try Schema.object(item, "tool call")
29
+ let arguments = try GeneratedContent(json: call["arguments"] as? String ?? "{}")
30
+ return Transcript.ToolCall(id: call["id"] as? String ?? UUID().uuidString, toolName: call["name"] as? String ?? "", arguments: arguments)
31
+ }
32
+ if !calls.isEmpty { entries.append(.toolCalls(Transcript.ToolCalls(calls))) }
33
+ case "tool":
34
+ entries.append(.toolOutput(Transcript.ToolOutput(
35
+ id: message["toolCallId"] as? String ?? "",
36
+ toolName: message["name"] as? String ?? "",
37
+ segments: segments(text)
38
+ )))
39
+ default:
40
+ throw Schema.Unsupported(description: "Unknown message role")
41
+ }
42
+ }
43
+ return Transcript(entries: entries)
44
+ }
45
+
46
+ static func segments(_ text: String) -> [Transcript.Segment] {
47
+ [.text(Transcript.TextSegment(content: text))]
48
+ }
49
+ }
@@ -0,0 +1,90 @@
1
+ import Foundation
2
+ import FoundationModels
3
+
4
+ /// A tool's parameters, or a structured answer's shape, as the adapter sends it: JSON
5
+ /// Schema already read into an ordered form (src/schema.ts), so a property keeps its
6
+ /// place (the model writes properties in the order it's given them). The kinds:
7
+ ///
8
+ /// - `object`: `name`, `description`, `properties` ([{ name, description, optional, schema }])
9
+ /// - `array`: `items`, `minimumElements`, `maximumElements`
10
+ /// - `string`: `choices` (an enum), `constant`
11
+ /// - `integer`, `number`: `minimum`, `maximum`
12
+ /// - `boolean`, `null`
13
+ /// - `anyOf`: `name`, `description`, `choices` (schemas)
14
+ /// - `reference`: `name`, one of the definitions
15
+ @available(iOS 26.0, macOS 26.0, *)
16
+ enum Schema {
17
+ struct Unsupported: Error, CustomStringConvertible {
18
+ let description: String
19
+ }
20
+
21
+ /// A generation schema from a root and its definitions (the `reference` targets).
22
+ static func generationSchema(_ value: Any?) throws -> GenerationSchema {
23
+ let json = try object(value, "schema")
24
+ let definitions = try (json["definitions"] as? [Any] ?? []).map(dynamicSchema)
25
+ return try GenerationSchema(root: dynamicSchema(json["root"]), dependencies: definitions)
26
+ }
27
+
28
+ static func dynamicSchema(_ value: Any?) throws -> DynamicGenerationSchema {
29
+ let json = try object(value, "schema")
30
+ let kind = json["kind"] as? String ?? ""
31
+ switch kind {
32
+ case "object":
33
+ let properties = try (json["properties"] as? [Any] ?? []).map { item -> DynamicGenerationSchema.Property in
34
+ let property = try object(item, "property")
35
+ return DynamicGenerationSchema.Property(
36
+ name: property["name"] as? String ?? "",
37
+ description: property["description"] as? String,
38
+ schema: try dynamicSchema(property["schema"]),
39
+ isOptional: property["optional"] as? Bool ?? false
40
+ )
41
+ }
42
+ return DynamicGenerationSchema(name: json["name"] as? String ?? "object", description: json["description"] as? String, properties: properties)
43
+ case "array":
44
+ return DynamicGenerationSchema(
45
+ arrayOf: try dynamicSchema(json["items"]),
46
+ minimumElements: json["minimumElements"] as? Int,
47
+ maximumElements: json["maximumElements"] as? Int
48
+ )
49
+ case "string":
50
+ if let constant = json["constant"] as? String {
51
+ return DynamicGenerationSchema(type: String.self, guides: [.constant(constant)])
52
+ }
53
+ if let choices = json["choices"] as? [String], !choices.isEmpty {
54
+ return DynamicGenerationSchema(type: String.self, guides: [.anyOf(choices)])
55
+ }
56
+ return DynamicGenerationSchema(type: String.self)
57
+ case "integer":
58
+ let minimum = (json["minimum"] as? NSNumber)?.intValue, maximum = (json["maximum"] as? NSNumber)?.intValue
59
+ var guides: [GenerationGuide<Int>] = []
60
+ if let minimum, let maximum { guides.append(.range(minimum...max(minimum, maximum))) }
61
+ else if let minimum { guides.append(.minimum(minimum)) }
62
+ else if let maximum { guides.append(.maximum(maximum)) }
63
+ return DynamicGenerationSchema(type: Int.self, guides: guides)
64
+ case "number":
65
+ let minimum = (json["minimum"] as? NSNumber)?.doubleValue, maximum = (json["maximum"] as? NSNumber)?.doubleValue
66
+ var guides: [GenerationGuide<Double>] = []
67
+ if let minimum, let maximum { guides.append(.range(minimum...max(minimum, maximum))) }
68
+ else if let minimum { guides.append(.minimum(minimum)) }
69
+ else if let maximum { guides.append(.maximum(maximum)) }
70
+ return DynamicGenerationSchema(type: Double.self, guides: guides)
71
+ case "boolean":
72
+ return DynamicGenerationSchema(type: Bool.self)
73
+ case "null":
74
+ if #available(iOS 26.4, macOS 26.4, *) { return .null }
75
+ throw Unsupported(description: "A null schema needs iOS 26.4 or macOS 26.4")
76
+ case "anyOf":
77
+ let choices = try (json["choices"] as? [Any] ?? []).map(dynamicSchema)
78
+ return DynamicGenerationSchema(name: json["name"] as? String ?? "choice", description: json["description"] as? String, anyOf: choices)
79
+ case "reference":
80
+ return DynamicGenerationSchema(referenceTo: json["name"] as? String ?? "")
81
+ default:
82
+ throw Unsupported(description: "Unknown schema kind \"\(kind)\"")
83
+ }
84
+ }
85
+
86
+ static func object(_ value: Any?, _ what: String) throws -> [String: Any] {
87
+ guard let object = value as? [String: Any] else { throw Unsupported(description: "Expected a \(what) object") }
88
+ return object
89
+ }
90
+ }
@@ -0,0 +1,89 @@
1
+ import Capacitor
2
+ import Foundation
3
+ import AfmCore
4
+
5
+ /// The Capacitor plugin: AfmBridge's calls, with its events sent to
6
+ /// JavaScript as `event`. Below iOS 26 the model is unavailable.
7
+ @objc(AfmPlugin)
8
+ public class AfmPlugin: CAPPlugin, CAPBridgedPlugin {
9
+ public let identifier = "AfmPlugin"
10
+ public let jsName = "Afm"
11
+ public let pluginMethods: [CAPPluginMethod] = [
12
+ CAPPluginMethod(name: "availability", returnType: CAPPluginReturnPromise),
13
+ CAPPluginMethod(name: "createSession", returnType: CAPPluginReturnPromise),
14
+ CAPPluginMethod(name: "prewarm", returnType: CAPPluginReturnPromise),
15
+ CAPPluginMethod(name: "respond", returnType: CAPPluginReturnPromise),
16
+ CAPPluginMethod(name: "submitToolResult", returnType: CAPPluginReturnPromise),
17
+ CAPPluginMethod(name: "cancel", returnType: CAPPluginReturnPromise),
18
+ CAPPluginMethod(name: "deleteSession", returnType: CAPPluginReturnPromise)
19
+ ]
20
+
21
+ /// The AfmBridge, made on first use.
22
+ private var afmObject: AnyObject?
23
+
24
+ @available(iOS 26.0, *)
25
+ private var afm: AfmBridge {
26
+ if let bridge = afmObject as? AfmBridge { return bridge }
27
+ let bridge = AfmBridge { [weak self] event in
28
+ self?.notifyListeners("event", data: event)
29
+ }
30
+ afmObject = bridge
31
+ return bridge
32
+ }
33
+
34
+ @objc func availability(_ call: CAPPluginCall) {
35
+ guard #available(iOS 26.0, *) else {
36
+ call.resolve(["available": false, "reason": "unsupportedOS"])
37
+ return
38
+ }
39
+ call.resolve(AfmBridge.availability())
40
+ }
41
+
42
+ @objc func createSession(_ call: CAPPluginCall) {
43
+ guard #available(iOS 26.0, *) else { return unsupported(call) }
44
+ perform(call) { try self.afm.createSession($0) }
45
+ }
46
+
47
+ @objc func prewarm(_ call: CAPPluginCall) {
48
+ guard #available(iOS 26.0, *) else { return unsupported(call) }
49
+ perform(call) { try self.afm.prewarm($0) }
50
+ }
51
+
52
+ @objc func respond(_ call: CAPPluginCall) {
53
+ guard #available(iOS 26.0, *) else { return unsupported(call) }
54
+ perform(call) { try self.afm.respond($0) }
55
+ }
56
+
57
+ @objc func submitToolResult(_ call: CAPPluginCall) {
58
+ guard #available(iOS 26.0, *) else { return unsupported(call) }
59
+ perform(call) { try self.afm.submitToolResult($0) }
60
+ }
61
+
62
+ @objc func cancel(_ call: CAPPluginCall) {
63
+ guard #available(iOS 26.0, *) else { return unsupported(call) }
64
+ perform(call) { try self.afm.cancel($0) }
65
+ }
66
+
67
+ @objc func deleteSession(_ call: CAPPluginCall) {
68
+ guard #available(iOS 26.0, *) else { return unsupported(call) }
69
+ perform(call) { self.afm.deleteSession($0["sessionId"] as? String ?? "") }
70
+ }
71
+
72
+ private func unsupported(_ call: CAPPluginCall) {
73
+ call.reject("Apple's on-device model needs iOS 26 or later", "unsupportedOS")
74
+ }
75
+
76
+ /// Runs a bridge call with the call's options, resolving or rejecting it.
77
+ private func perform(_ call: CAPPluginCall, _ body: ([String: Any]) throws -> Void) {
78
+ var options: [String: Any] = [:]
79
+ for (key, value) in call.options {
80
+ if let key = key as? String { options[key] = value }
81
+ }
82
+ do {
83
+ try body(options)
84
+ call.resolve()
85
+ } catch {
86
+ call.reject(String(describing: error))
87
+ }
88
+ }
89
+ }