apple-notes-mcp 2.9.10 → 2.9.12
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.
- package/README.md +42 -4
- package/build/index.js +597 -99
- package/native/public-helper/apple-notes-public-helper.swift +331 -5
- package/package.json +1 -1
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// A small command-line helper for apple-notes-mcp that uses PUBLIC Apple
|
|
4
4
|
// frameworks only. It never opens the Notes database and never writes to the
|
|
5
5
|
// Notes group container: the TypeScript server reads the bytes it needs
|
|
6
|
-
// (read-only) and hands them over on stdin
|
|
6
|
+
// (read-only) and hands them over on stdin, or names an audio file that the
|
|
7
|
+
// helper opens for reading only.
|
|
7
8
|
//
|
|
8
9
|
// Protocol: one JSON object on stdin, one JSON object on stdout.
|
|
9
10
|
// request: {"protocol": 1, "action": "<name>", ...fields}
|
|
@@ -15,6 +16,7 @@
|
|
|
15
16
|
// decode_drawing PencilKit drawing bytes (base64) -> strokes
|
|
16
17
|
// encode_drawing strokes -> PencilKit drawing bytes (base64); used to build
|
|
17
18
|
// synthetic test fixtures, never to write to Notes
|
|
19
|
+
// transcribe on-device Speech transcription of one audio file
|
|
18
20
|
//
|
|
19
21
|
// Build (done by `apple-notes-mcp setup --public-helper`, which also generates
|
|
20
22
|
// the one-line source-digest file that defines helperSourceSHA256 and the
|
|
@@ -23,9 +25,11 @@
|
|
|
23
25
|
// -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist -Xlinker Info.plist \
|
|
24
26
|
// apple-notes-public-helper.swift source-digest.swift -o apple-notes-public-helper
|
|
25
27
|
|
|
28
|
+
import AVFoundation
|
|
26
29
|
import AppKit
|
|
27
30
|
import Foundation
|
|
28
31
|
import PencilKit
|
|
32
|
+
import Speech
|
|
29
33
|
|
|
30
34
|
let protocolVersion = 1
|
|
31
35
|
let maxInputBytes = 96 * 1024 * 1024
|
|
@@ -211,11 +215,331 @@ func encodeDrawing(_ request: [String: Any]) throws -> [String: Any] {
|
|
|
211
215
|
return ["status": "ok", "strokeCount": strokes.count, "dataBase64": data.base64EncodedString()]
|
|
212
216
|
}
|
|
213
217
|
|
|
218
|
+
// MARK: - Transcription
|
|
219
|
+
//
|
|
220
|
+
// On-device only. On macOS 26 and later, SpeechAnalyzer with SpeechTranscriber
|
|
221
|
+
// runs entirely on the Mac (its language model is a local asset). On older
|
|
222
|
+
// systems, SFSpeechRecognizer is used with requiresOnDeviceRecognition = true,
|
|
223
|
+
// and a locale without on-device support is refused rather than sent to a
|
|
224
|
+
// server. The helper stops at its own deadline and returns what it has with
|
|
225
|
+
// complete = false, so the caller can report a partial transcript.
|
|
226
|
+
|
|
227
|
+
/// Final transcript segments gathered so far; an actor so the deadline path can read them.
|
|
228
|
+
actor TranscriptCollector {
|
|
229
|
+
private var segments: [String] = []
|
|
230
|
+
func add(_ segment: String) { segments.append(segment) }
|
|
231
|
+
func text() -> String { segments.joined(separator: " ") }
|
|
232
|
+
func isEmpty() -> Bool { segments.isEmpty }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/// The newest partial result (legacy recognizer), guarded by a lock because the
|
|
236
|
+
/// recognizer calls back on its own queue.
|
|
237
|
+
final class LatestText: @unchecked Sendable {
|
|
238
|
+
private let lock = NSLock()
|
|
239
|
+
private var value = ""
|
|
240
|
+
func set(_ text: String) { lock.withLock { value = text } }
|
|
241
|
+
func get() -> String { lock.withLock { value } }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
enum RaceOutcome {
|
|
245
|
+
case completed
|
|
246
|
+
case failed(Error)
|
|
247
|
+
case timedOut
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/// Resumes a continuation exactly once, whichever side of a race finishes first.
|
|
251
|
+
final class RaceGate: @unchecked Sendable {
|
|
252
|
+
private let lock = NSLock()
|
|
253
|
+
private var continuation: CheckedContinuation<RaceOutcome, Never>?
|
|
254
|
+
private var expired = false
|
|
255
|
+
init(_ continuation: CheckedContinuation<RaceOutcome, Never>) { self.continuation = continuation }
|
|
256
|
+
/// Called when the deadline passes, before `stop`: whatever `work` reports
|
|
257
|
+
/// afterwards (usually a cancellation error) is a timeout, not a failure.
|
|
258
|
+
func expire() { lock.withLock { expired = true } }
|
|
259
|
+
func resume(_ outcome: RaceOutcome) {
|
|
260
|
+
let (pending, final): (CheckedContinuation<RaceOutcome, Never>?, RaceOutcome) = lock.withLock {
|
|
261
|
+
defer { continuation = nil }
|
|
262
|
+
return (continuation, expired ? .timedOut : outcome)
|
|
263
|
+
}
|
|
264
|
+
pending?.resume(returning: final)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/// Runs `work` against a deadline without waiting for it after the deadline:
|
|
269
|
+
/// some framework calls (asset downloads) ignore cancellation, and the process
|
|
270
|
+
/// exits right after it answers anyway. On timeout, `stop` runs first so a
|
|
271
|
+
/// cooperative `work` can hand back what it has.
|
|
272
|
+
func race(
|
|
273
|
+
seconds: Double,
|
|
274
|
+
work: @escaping @Sendable () async throws -> Void,
|
|
275
|
+
stop: @escaping @Sendable () async -> Void
|
|
276
|
+
) async -> RaceOutcome {
|
|
277
|
+
await withCheckedContinuation { continuation in
|
|
278
|
+
let gate = RaceGate(continuation)
|
|
279
|
+
Task {
|
|
280
|
+
do {
|
|
281
|
+
try await work()
|
|
282
|
+
gate.resume(.completed)
|
|
283
|
+
} catch {
|
|
284
|
+
gate.resume(.failed(error))
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
Task {
|
|
288
|
+
try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
|
289
|
+
gate.expire()
|
|
290
|
+
await stop()
|
|
291
|
+
gate.resume(.timedOut)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
func authorizationName(_ status: SFSpeechRecognizerAuthorizationStatus) -> String {
|
|
297
|
+
switch status {
|
|
298
|
+
case .authorized: return "authorized"
|
|
299
|
+
case .denied: return "denied"
|
|
300
|
+
case .restricted: return "restricted"
|
|
301
|
+
case .notDetermined: return "notDetermined"
|
|
302
|
+
@unknown default: return "unknown"
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Speech Recognition access. The helper runs under an MCP server that nobody
|
|
307
|
+
// may be watching, so it never calls SFSpeechRecognizer.requestAuthorization:
|
|
308
|
+
// a system prompt would block the helper until the server killed it. It reads
|
|
309
|
+
// the current status instead and stops with `permission_required` when the
|
|
310
|
+
// engine it is about to use needs a grant that is missing.
|
|
311
|
+
|
|
312
|
+
/// The failure returned instead of prompting. The grant belongs to the app that
|
|
313
|
+
/// runs the MCP server (Claude, a terminal, ...), which macOS treats as responsible.
|
|
314
|
+
func permissionRequired(_ status: SFSpeechRecognizerAuthorizationStatus) -> HelperFailure {
|
|
315
|
+
let settings = "System Settings > Privacy & Security > Speech Recognition"
|
|
316
|
+
let detail: String
|
|
317
|
+
switch status {
|
|
318
|
+
case .denied:
|
|
319
|
+
detail = "Speech Recognition access was denied for the app running this server. Turn it on in \(settings), then try again."
|
|
320
|
+
case .restricted:
|
|
321
|
+
detail = "Speech Recognition is restricted on this Mac (for example by a device management profile), so it cannot be used."
|
|
322
|
+
default:
|
|
323
|
+
detail = "Speech Recognition access has not been granted to the app running this server, and the server never shows the permission prompt. Allow the app in \(settings), then try again."
|
|
324
|
+
}
|
|
325
|
+
return HelperFailure(code: "permission_required", message: detail)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/// Stops before any Speech call when access is missing. The legacy recognizer
|
|
329
|
+
/// needs `.authorized`. SpeechAnalyzer (macOS 26+) runs on-device without a
|
|
330
|
+
/// grant and does not prompt, so there only an explicit refusal (`.denied` or
|
|
331
|
+
/// `.restricted`) stops it; that also covers a future macOS that gates it.
|
|
332
|
+
func checkSpeechAccess(requireGrant: Bool) throws {
|
|
333
|
+
let status = SFSpeechRecognizer.authorizationStatus()
|
|
334
|
+
if status == .authorized { return }
|
|
335
|
+
if requireGrant || status == .denied || status == .restricted {
|
|
336
|
+
throw permissionRequired(status)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/// Maps a Speech error to a stable code. Permission is decided from the
|
|
341
|
+
/// explicit authorization status, never from the error's text.
|
|
342
|
+
func speechFailure(_ error: Error, requireGrant: Bool) -> HelperFailure {
|
|
343
|
+
if let failure = error as? HelperFailure { return failure }
|
|
344
|
+
do {
|
|
345
|
+
try checkSpeechAccess(requireGrant: requireGrant)
|
|
346
|
+
} catch let failure as HelperFailure {
|
|
347
|
+
return failure
|
|
348
|
+
} catch {}
|
|
349
|
+
return HelperFailure(code: "transcription_failed", message: error.localizedDescription)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/// Makes sure the locale's on-device model is installed. A download starts only
|
|
353
|
+
/// when the caller opted in: otherwise a missing model is `asset_unavailable` at once.
|
|
354
|
+
@available(macOS 26, *)
|
|
355
|
+
func ensureSpeechAssets(_ transcriber: SpeechTranscriber, localeID: String, allowDownload: Bool) async throws {
|
|
356
|
+
switch await AssetInventory.status(forModules: [transcriber]) {
|
|
357
|
+
case .installed:
|
|
358
|
+
return
|
|
359
|
+
case .unsupported:
|
|
360
|
+
throw HelperFailure(code: "unsupported_locale", message: "No on-device speech model for \(localeID)")
|
|
361
|
+
case .downloading where !allowDownload:
|
|
362
|
+
throw HelperFailure(
|
|
363
|
+
code: "asset_unavailable",
|
|
364
|
+
message: "macOS is still downloading the on-device speech model for \(localeID); try again shortly"
|
|
365
|
+
)
|
|
366
|
+
default:
|
|
367
|
+
if !allowDownload {
|
|
368
|
+
throw HelperFailure(
|
|
369
|
+
code: "asset_unavailable",
|
|
370
|
+
message: "The on-device speech model for \(localeID) is not installed. Retry with downloadAssets: true to let macOS download it."
|
|
371
|
+
)
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
guard let installation = try await AssetInventory.assetInstallationRequest(supporting: [transcriber]) else {
|
|
375
|
+
return
|
|
376
|
+
}
|
|
377
|
+
let outcome = await race(seconds: 90, work: { try await installation.downloadAndInstall() }, stop: {})
|
|
378
|
+
switch outcome {
|
|
379
|
+
case .completed:
|
|
380
|
+
return
|
|
381
|
+
case .failed(let error):
|
|
382
|
+
throw HelperFailure(
|
|
383
|
+
code: "asset_unavailable",
|
|
384
|
+
message: "Could not install the on-device speech model for \(localeID): \(error.localizedDescription)"
|
|
385
|
+
)
|
|
386
|
+
case .timedOut:
|
|
387
|
+
throw HelperFailure(
|
|
388
|
+
code: "asset_unavailable",
|
|
389
|
+
message: "The on-device speech model for \(localeID) is still downloading; try again shortly"
|
|
390
|
+
)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
@available(macOS 26, *)
|
|
395
|
+
func transcribeWithAnalyzer(
|
|
396
|
+
file: AVAudioFile, localeID: String, deadline: Double, allowDownload: Bool
|
|
397
|
+
) async throws -> [String: Any] {
|
|
398
|
+
try checkSpeechAccess(requireGrant: false)
|
|
399
|
+
guard SpeechTranscriber.isAvailable else {
|
|
400
|
+
throw HelperFailure(code: "speech_unavailable", message: "On-device transcription is not available on this Mac")
|
|
401
|
+
}
|
|
402
|
+
guard let locale = await SpeechTranscriber.supportedLocale(equivalentTo: Locale(identifier: localeID)) else {
|
|
403
|
+
throw HelperFailure(code: "unsupported_locale", message: "No on-device transcription for \(localeID)")
|
|
404
|
+
}
|
|
405
|
+
let transcriber = SpeechTranscriber(locale: locale, preset: .transcription)
|
|
406
|
+
try await ensureSpeechAssets(transcriber, localeID: localeID, allowDownload: allowDownload)
|
|
407
|
+
let analyzer = SpeechAnalyzer(modules: [transcriber])
|
|
408
|
+
let collector = TranscriptCollector()
|
|
409
|
+
let reader = Task {
|
|
410
|
+
for try await result in transcriber.results where result.isFinal {
|
|
411
|
+
let segment = String(result.text.characters).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
412
|
+
if !segment.isEmpty { await collector.add(segment) }
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
let outcome = await race(
|
|
416
|
+
seconds: deadline,
|
|
417
|
+
work: {
|
|
418
|
+
_ = try await analyzer.analyzeSequence(from: file)
|
|
419
|
+
try await analyzer.finalizeAndFinishThroughEndOfInput()
|
|
420
|
+
try await reader.value
|
|
421
|
+
},
|
|
422
|
+
stop: {
|
|
423
|
+
await analyzer.cancelAndFinishNow()
|
|
424
|
+
reader.cancel()
|
|
425
|
+
}
|
|
426
|
+
)
|
|
427
|
+
var response: [String: Any] = ["status": "ok", "engine": "SpeechAnalyzer", "locale": locale.identifier(.bcp47)]
|
|
428
|
+
switch outcome {
|
|
429
|
+
case .completed:
|
|
430
|
+
response["complete"] = true
|
|
431
|
+
case .timedOut:
|
|
432
|
+
response["complete"] = false
|
|
433
|
+
response["stopReason"] = "deadline"
|
|
434
|
+
case .failed(let error):
|
|
435
|
+
await analyzer.cancelAndFinishNow()
|
|
436
|
+
reader.cancel()
|
|
437
|
+
let failure = speechFailure(error, requireGrant: false)
|
|
438
|
+
if await collector.isEmpty() { throw failure }
|
|
439
|
+
response["complete"] = false
|
|
440
|
+
response["stopReason"] = failure.message
|
|
441
|
+
}
|
|
442
|
+
response["transcript"] = await collector.text()
|
|
443
|
+
return response
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/// Pre-macOS 26 path: SFSpeechRecognizer, forced on-device. It needs a Speech
|
|
447
|
+
/// Recognition grant, which is checked here and never requested.
|
|
448
|
+
func transcribeWithRecognizer(url: URL, localeID: String, deadline: Double) async throws -> [String: Any] {
|
|
449
|
+
try checkSpeechAccess(requireGrant: true)
|
|
450
|
+
guard let recognizer = SFSpeechRecognizer(locale: Locale(identifier: localeID)) else {
|
|
451
|
+
throw HelperFailure(code: "unsupported_locale", message: "No speech recognizer for \(localeID)")
|
|
452
|
+
}
|
|
453
|
+
guard recognizer.supportsOnDeviceRecognition else {
|
|
454
|
+
throw HelperFailure(code: "unsupported_locale", message: "No on-device recognition for \(localeID)")
|
|
455
|
+
}
|
|
456
|
+
recognizer.queue = OperationQueue()
|
|
457
|
+
let request = SFSpeechURLRecognitionRequest(url: url)
|
|
458
|
+
request.requiresOnDeviceRecognition = true
|
|
459
|
+
request.shouldReportPartialResults = true
|
|
460
|
+
let latest = LatestText()
|
|
461
|
+
final class TaskBox: @unchecked Sendable { var task: SFSpeechRecognitionTask? }
|
|
462
|
+
let box = TaskBox()
|
|
463
|
+
let outcome = await race(
|
|
464
|
+
seconds: deadline,
|
|
465
|
+
work: {
|
|
466
|
+
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
|
467
|
+
var resumed = false
|
|
468
|
+
box.task = recognizer.recognitionTask(with: request) { result, error in
|
|
469
|
+
if resumed { return }
|
|
470
|
+
if let result {
|
|
471
|
+
latest.set(result.bestTranscription.formattedString)
|
|
472
|
+
if result.isFinal {
|
|
473
|
+
resumed = true
|
|
474
|
+
continuation.resume()
|
|
475
|
+
return
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if let error {
|
|
479
|
+
resumed = true
|
|
480
|
+
continuation.resume(throwing: error)
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
},
|
|
485
|
+
stop: { box.task?.cancel() }
|
|
486
|
+
)
|
|
487
|
+
// Partial results replace each other; the newest one is the transcript so far.
|
|
488
|
+
let transcript = await latest.get()
|
|
489
|
+
var response: [String: Any] = ["status": "ok", "engine": "SFSpeechRecognizer", "locale": localeID]
|
|
490
|
+
switch outcome {
|
|
491
|
+
case .completed:
|
|
492
|
+
response["complete"] = true
|
|
493
|
+
case .timedOut:
|
|
494
|
+
response["complete"] = false
|
|
495
|
+
response["stopReason"] = "deadline"
|
|
496
|
+
case .failed(let error):
|
|
497
|
+
let failure = speechFailure(error, requireGrant: true)
|
|
498
|
+
if transcript.isEmpty { throw failure }
|
|
499
|
+
response["complete"] = false
|
|
500
|
+
response["stopReason"] = failure.message
|
|
501
|
+
}
|
|
502
|
+
response["transcript"] = transcript
|
|
503
|
+
return response
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
func transcribe(_ request: [String: Any]) async throws -> [String: Any] {
|
|
507
|
+
guard let path = request["path"] as? String, path.hasPrefix("/") else {
|
|
508
|
+
throw HelperFailure(code: "invalid_request", message: "path must be an absolute file path")
|
|
509
|
+
}
|
|
510
|
+
let localeID = request["locale"] as? String ?? "en-US"
|
|
511
|
+
let deadline = min(max((request["timeoutSeconds"] as? NSNumber)?.doubleValue ?? 300, 5), 3600)
|
|
512
|
+
var isDirectory: ObjCBool = false
|
|
513
|
+
guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory), !isDirectory.boolValue else {
|
|
514
|
+
throw HelperFailure(code: "file_not_found", message: "The audio file does not exist")
|
|
515
|
+
}
|
|
516
|
+
let url = URL(fileURLWithPath: path)
|
|
517
|
+
let file: AVAudioFile
|
|
518
|
+
do {
|
|
519
|
+
file = try AVAudioFile(forReading: url)
|
|
520
|
+
} catch {
|
|
521
|
+
throw HelperFailure(code: "unsupported_audio", message: "Could not read the audio: \(error.localizedDescription)")
|
|
522
|
+
}
|
|
523
|
+
let duration = file.fileFormat.sampleRate > 0 ? Double(file.length) / file.fileFormat.sampleRate : 0
|
|
524
|
+
var response: [String: Any]
|
|
525
|
+
if #available(macOS 26, *) {
|
|
526
|
+
response = try await transcribeWithAnalyzer(
|
|
527
|
+
file: file, localeID: localeID, deadline: deadline,
|
|
528
|
+
allowDownload: request["downloadAssets"] as? Bool ?? false
|
|
529
|
+
)
|
|
530
|
+
} else {
|
|
531
|
+
response = try await transcribeWithRecognizer(url: url, localeID: localeID, deadline: deadline)
|
|
532
|
+
}
|
|
533
|
+
response["durationSeconds"] = Int(duration.rounded())
|
|
534
|
+
response["speechAuthorization"] = authorizationName(SFSpeechRecognizer.authorizationStatus())
|
|
535
|
+
return response
|
|
536
|
+
}
|
|
537
|
+
|
|
214
538
|
// MARK: - Dispatch
|
|
215
539
|
|
|
216
|
-
let actions = ["hello", "decode_drawing", "encode_drawing"]
|
|
540
|
+
let actions = ["hello", "decode_drawing", "encode_drawing", "transcribe"]
|
|
217
541
|
|
|
218
|
-
func handle(_ request: [String: Any]) throws -> [String: Any] {
|
|
542
|
+
func handle(_ request: [String: Any]) async throws -> [String: Any] {
|
|
219
543
|
guard (request["protocol"] as? NSNumber)?.intValue == protocolVersion else {
|
|
220
544
|
throw HelperFailure(code: "protocol_mismatch", message: "expected protocol \(protocolVersion)")
|
|
221
545
|
}
|
|
@@ -231,6 +555,8 @@ func handle(_ request: [String: Any]) throws -> [String: Any] {
|
|
|
231
555
|
return try decodeDrawing(request)
|
|
232
556
|
case "encode_drawing":
|
|
233
557
|
return try encodeDrawing(request)
|
|
558
|
+
case "transcribe":
|
|
559
|
+
return try await transcribe(request)
|
|
234
560
|
default:
|
|
235
561
|
throw HelperFailure(code: "unknown_action", message: "unknown action")
|
|
236
562
|
}
|
|
@@ -238,7 +564,7 @@ func handle(_ request: [String: Any]) throws -> [String: Any] {
|
|
|
238
564
|
|
|
239
565
|
@main
|
|
240
566
|
struct PublicHelper {
|
|
241
|
-
static func main() {
|
|
567
|
+
static func main() async {
|
|
242
568
|
let input = FileHandle.standardInput.readDataToEndOfFile()
|
|
243
569
|
if input.count > maxInputBytes {
|
|
244
570
|
fail(HelperFailure(code: "invalid_request", message: "request exceeds \(maxInputBytes) bytes"))
|
|
@@ -247,7 +573,7 @@ struct PublicHelper {
|
|
|
247
573
|
fail(HelperFailure(code: "invalid_request", message: "stdin must be one JSON object"))
|
|
248
574
|
}
|
|
249
575
|
do {
|
|
250
|
-
finish(try handle(request))
|
|
576
|
+
finish(try await handle(request))
|
|
251
577
|
} catch let failure as HelperFailure {
|
|
252
578
|
fail(failure)
|
|
253
579
|
} catch {
|
package/package.json
CHANGED