pi-meta-oauth 0.1.1 → 0.2.0

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 CHANGED
@@ -1,19 +1,22 @@
1
+ <img width="3840" height="2160" alt="Meta-Symbol" src="https://github.com/user-attachments/assets/a3074df9-ad80-40ca-b9b9-944b96dda192" />
2
+
1
3
  # pi-meta-oauth
2
4
 
3
- Meta Model API OAuth provider for [pi](https://pi.dev) — use **Muse Spark** (`muse-spark-1.2`, `muse-spark-1.2-contributor`, `muse-spark-1.1`) via `openai-responses` inside pi.
5
+ Meta Model API OAuth and Muse-style voice input for [pi](https://pi.dev).
4
6
 
5
- - Device authorization (same client as Muse Code) against `https://auth.meta.com`
6
- - Mints a Model API key via `POST https://api.meta.ai/muse-code/key`
7
- - Stores OAuth `refresh = identity token` + `access = Model API key` in `~/.pi/agent/auth.json` under provider `meta`
8
- - Refresh re-mints the Model API key daily
9
- - Model catalog from `GET https://api.meta.ai/v1/models` with thinking levels `minimal/low/medium/high/xhigh`
7
+ - Use Muse Spark models through Pi's `openai-responses` provider
8
+ - Device authorization against `https://auth.meta.com`
9
+ - Model API-key minting through `POST https://api.meta.ai/muse-code/key`
10
+ - Dynamic Muse model catalog from `GET https://api.meta.ai/v1/models`
11
+ - Toggle-based Meta voice dictation on macOS with a live green input meter
10
12
 
11
13
  ## Install
12
14
 
13
15
  ```bash
14
- # local path (no publish needed)
15
- pi install ./pi-meta-provider
16
- # or npm (after publish)
16
+ # Local checkout
17
+ pi install /absolute/path/to/pi-meta-oauth
18
+
19
+ # npm
17
20
  pi install npm:pi-meta-oauth
18
21
 
19
22
  pi --list-models meta
@@ -21,29 +24,51 @@ pi --list-models meta
21
24
 
22
25
  ## Login
23
26
 
24
- ```bash
25
- pi
27
+ ```text
26
28
  /login meta
27
- # shows device code -> approve in browser -> mints Model API key automatically
28
29
  ```
29
30
 
30
- Stored credential shape:
31
+ Pi displays a device code, opens the Meta authorization flow, and mints a Model API key. Credentials are stored by Pi in `~/.pi/agent/auth.json` under provider `meta`:
31
32
 
32
33
  ```json
33
34
  { "meta": { "type": "oauth", "refresh": "<identity>", "access": "<MODEL_API_KEY>", "expires": 123 } }
34
35
  ```
35
36
 
37
+ The access key is re-minted daily.
38
+
39
+ ## Voice input
40
+
41
+ Voice mode currently requires macOS. It uses the same Meta credential managed by this provider; a separate Muse login is not required.
42
+
43
+ 1. Press **Alt+V** once to start recording.
44
+ 2. Speak while the green microphone meter is visible in Pi's status bar.
45
+ 3. Press **Alt+V** again to stop.
46
+ 4. The transcript is inserted into Pi's editor for review before submission.
47
+
48
+ Commands:
49
+
50
+ - `/voice` — show voice status and privacy behavior
51
+ - `/voice-on` — enable the Alt+V shortcut
52
+ - `/voice-off` — disable voice input
53
+
54
+ The first recording may trigger the macOS microphone permission prompt. Audio is captured only between the two Alt+V presses and streamed as 16 kHz mono PCM to Muse Code's internal Meta ASR endpoint. No local speech-recognition model is downloaded. The endpoint is undocumented and may change in a future Muse release.
55
+
56
+ Optional overrides:
57
+
58
+ - `PI_META_VOICE_ASR_ENDPOINT`
59
+ - `PI_META_VOICE_ASR_MODEL`
60
+
36
61
  ## Models
37
62
 
38
- All three share 1,048,576 context, 256K output, `supportsReasoningEffort: true`, and `input: ["text","image"]`:
63
+ Fallback models use a 1,048,576-token context window, up to 256K output tokens, image input, and reasoning levels `minimal`, `low`, `medium`, `high`, and `xhigh`.
39
64
 
40
65
  | id | pricing (input/output/cached) $/M |
41
- |---|---|
66
+ | --- | --- |
42
67
  | `muse-spark-1.2` | 1.25 / 4.25 / 0.15 |
43
68
  | `muse-spark-1.2-contributor` | 0.10 / 0.20 / 0.002 |
44
69
  | `muse-spark-1.1` | 1.25 / 4.25 / 0.15 |
45
70
 
46
- Add to `enabledModels` in `~/.pi/agent/settings.json`:
71
+ To scope Pi's model picker to Meta models:
47
72
 
48
73
  ```json
49
74
  { "enabledModels": ["meta/*"] }
@@ -54,16 +79,20 @@ Add to `enabledModels` in `~/.pi/agent/settings.json`:
54
79
  ```bash
55
80
  pi --list-models meta
56
81
  pi -p --provider meta --model muse-spark-1.2 "Reply exactly: META_OK"
57
- bun test extensions/meta.test.ts
82
+ bun run typecheck
83
+ bun test
58
84
  ```
59
85
 
60
86
  ## Publish to pi.dev
61
87
 
62
- `pi-package` keyword makes it appear at https://pi.dev/packages
88
+ The `pi-package` keyword makes the package discoverable at <https://pi.dev/packages>.
63
89
 
64
90
  ```bash
65
91
  npm publish --access public
66
- # users then run: pi install npm:pi-meta-oauth
67
92
  ```
68
93
 
69
- See `extensions/meta.ts` for the `registerProvider` / `refreshModels` / `oauth` implementation reused from Muse Code's launcher flow.
94
+ Users can then update with:
95
+
96
+ ```bash
97
+ pi update npm:pi-meta-oauth
98
+ ```
@@ -0,0 +1,8 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>com.apple.security.device.audio-input</key>
6
+ <true/>
7
+ </dict>
8
+ </plist>
@@ -0,0 +1,22 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>CFBundleDevelopmentRegion</key>
6
+ <string>en</string>
7
+ <key>CFBundleDisplayName</key>
8
+ <string>Pi Meta OAuth Voice</string>
9
+ <key>CFBundleExecutable</key>
10
+ <string>pi-meta-oauth-voice-v1</string>
11
+ <key>CFBundleIdentifier</key>
12
+ <string>dev.pi.metaoauth.voice</string>
13
+ <key>CFBundleName</key>
14
+ <string>Pi Meta OAuth Voice</string>
15
+ <key>CFBundlePackageType</key>
16
+ <string>APPL</string>
17
+ <key>CFBundleShortVersionString</key>
18
+ <string>2.0</string>
19
+ <key>NSMicrophoneUsageDescription</key>
20
+ <string>Pi records audio after Alt+V starts voice input and stops when Alt+V is pressed again, then sends it to Meta ASR for transcription.</string>
21
+ </dict>
22
+ </plist>
@@ -0,0 +1,182 @@
1
+ import AVFoundation
2
+ import Foundation
3
+
4
+ private let outputQueue = DispatchQueue(label: "com.pi.muse.meta-voice.output")
5
+
6
+ private func emitNow(_ type: String, _ fields: [String: Any] = [:]) {
7
+ var payload = fields
8
+ payload["type"] = type
9
+ guard let data = try? JSONSerialization.data(withJSONObject: payload),
10
+ var line = String(data: data, encoding: .utf8) else {
11
+ return
12
+ }
13
+ line.append("\n")
14
+ FileHandle.standardOutput.write(Data(line.utf8))
15
+ }
16
+
17
+ private func emit(_ type: String, _ fields: [String: Any] = [:]) {
18
+ outputQueue.async {
19
+ emitNow(type, fields)
20
+ }
21
+ }
22
+
23
+ private final class MicrophoneSession {
24
+ private let audioEngine = AVAudioEngine()
25
+ private var converter: AVAudioConverter?
26
+ private var stopping = false
27
+ private var finished = false
28
+
29
+ func start() {
30
+ requestMicrophoneAccess()
31
+ }
32
+
33
+ private func requestMicrophoneAccess() {
34
+ switch AVCaptureDevice.authorizationStatus(for: .audio) {
35
+ case .authorized:
36
+ beginCapture()
37
+ case .notDetermined:
38
+ AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
39
+ DispatchQueue.main.async {
40
+ guard let self else { return }
41
+ if granted {
42
+ self.beginCapture()
43
+ } else {
44
+ self.fail("Microphone permission was not granted. Enable it in System Settings → Privacy & Security → Microphone.", exitCode: 2)
45
+ }
46
+ }
47
+ }
48
+ default:
49
+ fail("Microphone permission is disabled. Enable it in System Settings → Privacy & Security → Microphone.", exitCode: 2)
50
+ }
51
+ }
52
+
53
+ private func beginCapture() {
54
+ let inputNode = audioEngine.inputNode
55
+ let inputFormat = inputNode.outputFormat(forBus: 0)
56
+ guard inputFormat.sampleRate > 0, inputFormat.channelCount > 0 else {
57
+ fail("The selected microphone returned an invalid audio format.", exitCode: 3)
58
+ return
59
+ }
60
+ guard let outputFormat = AVAudioFormat(
61
+ commonFormat: .pcmFormatInt16,
62
+ sampleRate: 16_000,
63
+ channels: 1,
64
+ interleaved: false
65
+ ), let converter = AVAudioConverter(from: inputFormat, to: outputFormat) else {
66
+ fail("Could not initialize 16 kHz microphone conversion.", exitCode: 3)
67
+ return
68
+ }
69
+ self.converter = converter
70
+
71
+ let tap: AVAudioNodeTapBlock = { [weak self] buffer, _ in
72
+ self?.convertAndEmit(buffer, outputFormat: outputFormat)
73
+ }
74
+ let tapBufferSize = AVAudioFrameCount(inputFormat.sampleRate * 0.1)
75
+ do {
76
+ if #available(macOS 27.0, *) {
77
+ try inputNode.__installTap(
78
+ onBus: 0,
79
+ bufferSize: tapBufferSize,
80
+ format: inputFormat,
81
+ error: (),
82
+ block: tap
83
+ )
84
+ } else {
85
+ inputNode.installTap(
86
+ onBus: 0,
87
+ bufferSize: tapBufferSize,
88
+ format: inputFormat,
89
+ block: tap
90
+ )
91
+ }
92
+ audioEngine.prepare()
93
+ try audioEngine.start()
94
+ } catch {
95
+ inputNode.removeTap(onBus: 0)
96
+ fail("Could not start microphone capture: \(error.localizedDescription)", exitCode: 4)
97
+ return
98
+ }
99
+
100
+ emit("ready", [
101
+ "sampleRate": 16_000,
102
+ "channels": 1,
103
+ "encoding": "pcm_s16le"
104
+ ])
105
+
106
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
107
+ _ = readLine()
108
+ DispatchQueue.main.async {
109
+ self?.stop()
110
+ }
111
+ }
112
+ }
113
+
114
+ private func convertAndEmit(_ input: AVAudioPCMBuffer, outputFormat: AVAudioFormat) {
115
+ guard !stopping, !finished, let converter else { return }
116
+ let ratio = outputFormat.sampleRate / input.format.sampleRate
117
+ let capacity = AVAudioFrameCount(ceil(Double(input.frameLength) * ratio)) + 32
118
+ guard let output = AVAudioPCMBuffer(pcmFormat: outputFormat, frameCapacity: capacity) else {
119
+ return
120
+ }
121
+
122
+ var suppliedInput = false
123
+ var conversionError: NSError?
124
+ let status = converter.convert(to: output, error: &conversionError) { _, inputStatus in
125
+ if suppliedInput {
126
+ inputStatus.pointee = .noDataNow
127
+ return nil
128
+ }
129
+ suppliedInput = true
130
+ inputStatus.pointee = .haveData
131
+ return input
132
+ }
133
+
134
+ if let conversionError {
135
+ emit("error", ["message": "Microphone conversion failed: \(conversionError.localizedDescription)"])
136
+ return
137
+ }
138
+ guard status != .error,
139
+ output.frameLength > 0,
140
+ let samples = output.int16ChannelData?[0] else {
141
+ return
142
+ }
143
+
144
+ let byteCount = Int(output.frameLength) * MemoryLayout<Int16>.size
145
+ let audio = Data(bytes: samples, count: byteCount)
146
+ emit("audio", ["data": audio.base64EncodedString()])
147
+ }
148
+
149
+ func stop() {
150
+ guard !stopping, !finished else { return }
151
+ stopping = true
152
+ if audioEngine.isRunning {
153
+ audioEngine.stop()
154
+ }
155
+ audioEngine.inputNode.removeTap(onBus: 0)
156
+ converter = nil
157
+ finished = true
158
+ outputQueue.async {
159
+ emitNow("stopped")
160
+ fflush(stdout)
161
+ exit(0)
162
+ }
163
+ }
164
+
165
+ private func fail(_ message: String, exitCode: Int32) {
166
+ guard !finished else { return }
167
+ finished = true
168
+ if audioEngine.isRunning {
169
+ audioEngine.stop()
170
+ audioEngine.inputNode.removeTap(onBus: 0)
171
+ }
172
+ outputQueue.async {
173
+ emitNow("error", ["message": message])
174
+ fflush(stdout)
175
+ exit(exitCode)
176
+ }
177
+ }
178
+ }
179
+
180
+ private let session = MicrophoneSession()
181
+ session.start()
182
+ RunLoop.main.run()