@monotykamary/pi-better-openai 0.1.23

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.
@@ -0,0 +1,197 @@
1
+ import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import { getCodexCredentials } from "../codex-auth.ts";
4
+ import type { ResolvedConfig } from "../config.ts";
5
+ import { sanitizeDiagnosticError } from "../format.ts";
6
+ import {
7
+ LiveSessionController,
8
+ type LiveSessionControllerOptions,
9
+ type LiveTranscript,
10
+ } from "./controller.ts";
11
+ import { LiveVisualizer, LIVE_VISUALIZER_TOGGLE_KEY } from "./visualizer.ts";
12
+
13
+ export const LIVE_COMMAND = "live";
14
+ export const LIVE_DELEGATION_MESSAGE_TYPE = "better-openai-live-delegation";
15
+
16
+ interface LiveSessionRuntime {
17
+ start(): Promise<void>;
18
+ stop(): Promise<void>;
19
+ toggleMute(): void;
20
+ handleAgentMessage(message: unknown): void;
21
+ handleAgentSettled(): void;
22
+ }
23
+
24
+ interface ActiveLiveRun {
25
+ session: LiveSessionRuntime;
26
+ finishUi(result: LiveUiResult): void;
27
+ }
28
+
29
+ type LiveUiResult = { error?: Error };
30
+
31
+ type LiveSessionFactory = (options: LiveSessionControllerOptions) => LiveSessionRuntime;
32
+
33
+ export interface LiveRegistrationDependencies {
34
+ createSession?: LiveSessionFactory;
35
+ }
36
+
37
+ function messageText(content: unknown): string {
38
+ if (typeof content === "string") return content;
39
+ if (!Array.isArray(content)) return "";
40
+ return content
41
+ .filter(
42
+ (item): item is { type: "text"; text: string } =>
43
+ typeof item === "object" &&
44
+ item !== null &&
45
+ "type" in item &&
46
+ item.type === "text" &&
47
+ "text" in item &&
48
+ typeof item.text === "string",
49
+ )
50
+ .map((item) => item.text)
51
+ .join("\n");
52
+ }
53
+
54
+ export function registerOpenAILive(
55
+ pi: ExtensionAPI,
56
+ getConfig: (ctx: ExtensionContext) => ResolvedConfig,
57
+ dependencies: LiveRegistrationDependencies = {},
58
+ ): { isActive(): boolean; stop(): Promise<void> } {
59
+ const createSession =
60
+ dependencies.createSession ?? ((options) => new LiveSessionController(options));
61
+ let activeRun: ActiveLiveRun | undefined;
62
+ let settling: Promise<void> | undefined;
63
+
64
+ async function stopRun(run: ActiveLiveRun): Promise<void> {
65
+ run.finishUi({});
66
+ await run.session.stop();
67
+ }
68
+
69
+ async function stopActive(): Promise<void> {
70
+ const run = activeRun;
71
+ if (!run) {
72
+ if (settling) await settling;
73
+ return;
74
+ }
75
+ await stopRun(run);
76
+ }
77
+
78
+ async function start(ctx: ExtensionContext): Promise<void> {
79
+ if (ctx.mode !== "tui") {
80
+ ctx.ui.notify("Live voice requires interactive TUI mode.", "warning");
81
+ return;
82
+ }
83
+ const cfg = getConfig(ctx);
84
+ if (!cfg.live.enabled) {
85
+ ctx.ui.notify("Live voice is disabled. Enable it in /openai-settings.", "warning");
86
+ return;
87
+ }
88
+ if (settling) await settling;
89
+
90
+ const result = await ctx.ui.custom<LiveUiResult>((tui, theme, _keybindings, done) => {
91
+ let completed = false;
92
+ let session: LiveSessionRuntime;
93
+ const finishUi = (value: LiveUiResult) => {
94
+ if (completed) return;
95
+ completed = true;
96
+ done(value);
97
+ };
98
+ const visualizer = new LiveVisualizer({
99
+ theme,
100
+ requestRender: () => tui.requestRender(),
101
+ onStop: () => finishUi({}),
102
+ onToggleMute: () => session.toggleMute(),
103
+ });
104
+ const updateTranscript = (transcript: LiveTranscript | undefined) => {
105
+ visualizer.setTranscript(transcript);
106
+ };
107
+ session = createSession({
108
+ sessionId: ctx.sessionManager.getSessionId(),
109
+ voice: cfg.live.voice,
110
+ getCredentials: (signal) => getCodexCredentials(ctx, signal),
111
+ delegate: (request) => {
112
+ pi.sendMessage(
113
+ {
114
+ customType: LIVE_DELEGATION_MESSAGE_TYPE,
115
+ content: request,
116
+ display: true,
117
+ details: { source: "live" },
118
+ },
119
+ { triggerTurn: true, deliverAs: "steer" },
120
+ );
121
+ },
122
+ callbacks: {
123
+ onPhase: (phase) => visualizer.setPhase(phase),
124
+ onLevels: (input) => visualizer.setInputLevel(input),
125
+ onTranscript: updateTranscript,
126
+ onTerminal: (error) => finishUi(error ? { error } : {}),
127
+ },
128
+ });
129
+ activeRun = { session, finishUi };
130
+ setImmediate(() => {
131
+ void session.start().catch((cause) => {
132
+ const error = cause instanceof Error ? cause : new Error(String(cause));
133
+ finishUi({ error });
134
+ });
135
+ });
136
+ return visualizer;
137
+ });
138
+
139
+ const run = activeRun;
140
+ if (run) {
141
+ const cleanup = stopRun(run).catch(() => undefined);
142
+ settling = cleanup;
143
+ await cleanup;
144
+ if (activeRun === run) activeRun = undefined;
145
+ if (settling === cleanup) settling = undefined;
146
+ }
147
+ if (result.error) ctx.ui.notify(sanitizeDiagnosticError(result.error.message), "error");
148
+ }
149
+
150
+ async function toggle(ctx: ExtensionContext): Promise<void> {
151
+ if (activeRun) {
152
+ activeRun.finishUi({});
153
+ return;
154
+ }
155
+ await start(ctx);
156
+ }
157
+
158
+ pi.registerMessageRenderer(LIVE_DELEGATION_MESSAGE_TYPE, (message, _options, theme) => {
159
+ const text = messageText(message.content).trim();
160
+ const label = theme.fg("accent", theme.bold("Live request"));
161
+ return new Text(`${label}\n${theme.fg("customMessageText", text)}`, 1, 0);
162
+ });
163
+
164
+ pi.registerCommand(LIVE_COMMAND, {
165
+ description: "Start or stop Codex-backed realtime voice mode",
166
+ handler: async (args, ctx) => {
167
+ if (args.trim()) {
168
+ ctx.ui.notify("Usage: /live", "error");
169
+ return;
170
+ }
171
+ await toggle(ctx);
172
+ },
173
+ });
174
+
175
+ pi.registerShortcut(LIVE_VISUALIZER_TOGGLE_KEY, {
176
+ description: "Start or stop Better OpenAI live voice mode",
177
+ handler: toggle,
178
+ });
179
+
180
+ pi.on("message_end", (event) => {
181
+ activeRun?.session.handleAgentMessage(event.message);
182
+ });
183
+
184
+ pi.on("agent_settled", () => {
185
+ activeRun?.session.handleAgentSettled();
186
+ });
187
+
188
+ pi.on("session_shutdown", async () => {
189
+ await stopActive();
190
+ activeRun = undefined;
191
+ });
192
+
193
+ return {
194
+ isActive: () => activeRun !== undefined || settling !== undefined,
195
+ stop: stopActive,
196
+ };
197
+ }
@@ -0,0 +1,109 @@
1
+ import { createRequire } from "node:module";
2
+
3
+ export interface LiveAudioCapture {
4
+ stop(): void;
5
+ }
6
+
7
+ export interface LiveAudioCaptureConstructor {
8
+ new (
9
+ sampleRate: number,
10
+ onAudio: (error: Error | null, samples: Float32Array) => void,
11
+ ): LiveAudioCapture;
12
+ }
13
+
14
+ export interface LiveWebRtcPeerInstance {
15
+ createOffer(): Promise<string>;
16
+ acceptAnswer(sdp: string): Promise<void>;
17
+ waitForOpen(timeoutMs?: number): Promise<void>;
18
+ pushAudio(samples: Float32Array): void;
19
+ setMuted(muted: boolean): void;
20
+ close(): Promise<void>;
21
+ }
22
+
23
+ export interface LiveWebRtcPeerConstructor {
24
+ new (
25
+ onEvent: (error: Error | null, payload: string) => void,
26
+ onLevel: (error: Error | null, level: number) => void,
27
+ onFailure: (error: Error | null, message: string) => void,
28
+ ): LiveWebRtcPeerInstance;
29
+ }
30
+
31
+ export type DeviceCheckTokenResult = {
32
+ supported: boolean;
33
+ tokenBase64?: string;
34
+ error?: string;
35
+ latencyMs: number;
36
+ };
37
+
38
+ export interface LiveNativeBindings {
39
+ AudioCapture: LiveAudioCaptureConstructor;
40
+ LiveWebRtcPeer: LiveWebRtcPeerConstructor;
41
+ deviceCheckGenerateToken(): Promise<DeviceCheckTokenResult>;
42
+ __ompInstallTokioRuntime(): void;
43
+ }
44
+
45
+ const NATIVE_PACKAGES = {
46
+ "darwin-arm64": "@oh-my-pi/pi-natives-darwin-arm64",
47
+ "darwin-x64": "@oh-my-pi/pi-natives-darwin-x64",
48
+ "linux-arm64": "@oh-my-pi/pi-natives-linux-arm64",
49
+ "linux-x64": "@oh-my-pi/pi-natives-linux-x64",
50
+ "win32-x64": "@oh-my-pi/pi-natives-win32-x64",
51
+ } as const;
52
+
53
+ const runtimeRequire = createRequire(import.meta.url);
54
+ let cachedBindings: LiveNativeBindings | undefined;
55
+
56
+ type NativeTarget = keyof typeof NATIVE_PACKAGES;
57
+
58
+ function isRecord(value: unknown): value is Record<string, unknown> {
59
+ return typeof value === "object" && value !== null;
60
+ }
61
+
62
+ export function resolveLiveNativePackage(
63
+ platform: NodeJS.Platform | string,
64
+ arch: string,
65
+ ): string | undefined {
66
+ return NATIVE_PACKAGES[`${platform}-${arch}` as NativeTarget];
67
+ }
68
+
69
+ export function validateLiveNativeBindings(value: unknown): LiveNativeBindings {
70
+ if (
71
+ !isRecord(value) ||
72
+ typeof value.AudioCapture !== "function" ||
73
+ typeof value.LiveWebRtcPeer !== "function" ||
74
+ typeof value.deviceCheckGenerateToken !== "function" ||
75
+ typeof value.__ompInstallTokioRuntime !== "function"
76
+ ) {
77
+ throw new Error(
78
+ "The installed live native package does not expose the required audio/WebRTC API.",
79
+ );
80
+ }
81
+ return value as unknown as LiveNativeBindings;
82
+ }
83
+
84
+ export function loadLiveNative(
85
+ requirePackage: (packageName: string) => unknown = runtimeRequire,
86
+ ): LiveNativeBindings {
87
+ if (cachedBindings && requirePackage === runtimeRequire) return cachedBindings;
88
+ const packageName = resolveLiveNativePackage(process.platform, process.arch);
89
+ if (!packageName) {
90
+ throw new Error(
91
+ `Live voice is not available on ${process.platform}-${process.arch}. Supported targets: ${Object.keys(NATIVE_PACKAGES).join(", ")}.`,
92
+ );
93
+ }
94
+
95
+ let loaded: unknown;
96
+ try {
97
+ loaded = requirePackage(packageName);
98
+ } catch (cause) {
99
+ const detail = cause instanceof Error ? cause.message : String(cause);
100
+ throw new Error(`Unable to load ${packageName}: ${detail}`, { cause });
101
+ }
102
+
103
+ const bindings = validateLiveNativeBindings(loaded);
104
+ bindings.__ompInstallTokioRuntime();
105
+ if (requirePackage === runtimeRequire) cachedBindings = bindings;
106
+ return bindings;
107
+ }
108
+
109
+ export const LIVE_NATIVE_TARGETS = Object.keys(NATIVE_PACKAGES);
@@ -0,0 +1,234 @@
1
+ export const LIVE_MODEL = "gpt-live-1-codex" as const;
2
+
3
+ export const CONTEXT_CHUNK_BYTES = 500;
4
+
5
+ export type LiveContextChannel = "speakable" | "commentary";
6
+
7
+ export type LiveInputTextContent = { type: "input_text"; text: string };
8
+
9
+ export type LiveSessionPayload = {
10
+ model: typeof LIVE_MODEL;
11
+ instructions: string;
12
+ audio: { output: { voice: string } };
13
+ delegation: { type: "client" };
14
+ };
15
+
16
+ export type LiveClientMessage =
17
+ | {
18
+ type: "delegation.context.append";
19
+ delegation_item_id: string;
20
+ channel?: LiveContextChannel;
21
+ content: LiveInputTextContent[];
22
+ }
23
+ | {
24
+ type: "session.context.append";
25
+ channel?: LiveContextChannel;
26
+ content: LiveInputTextContent[];
27
+ }
28
+ | { type: "session.close" };
29
+
30
+ export type LiveServerEvent =
31
+ | {
32
+ type: "session.started" | "session.updated";
33
+ session: { id: string; instructions?: string };
34
+ }
35
+ | { type: "output_audio.delta"; audio: string }
36
+ | { type: "input_transcript.added" | "output_transcript.added"; item: { text: string } }
37
+ | { type: "turn.done"; turn: { role: "user" | "assistant"; transcript: string } }
38
+ | {
39
+ type: "delegation.created";
40
+ item: {
41
+ type: "delegation";
42
+ target: "client";
43
+ id: string;
44
+ content: LiveInputTextContent[];
45
+ };
46
+ }
47
+ | { type: "error"; message: string }
48
+ | { type: "unknown"; wireType: string };
49
+
50
+ type UnknownRecord = Record<string, unknown>;
51
+
52
+ function isRecord(value: unknown): value is UnknownRecord {
53
+ return typeof value === "object" && value !== null && !Array.isArray(value);
54
+ }
55
+
56
+ function parsePayload(payload: unknown): UnknownRecord | null {
57
+ let parsed = payload;
58
+ if (typeof payload === "string") {
59
+ try {
60
+ parsed = JSON.parse(payload) as unknown;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+ return isRecord(parsed) ? parsed : null;
66
+ }
67
+
68
+ function parseSessionEvent(
69
+ type: "session.started" | "session.updated",
70
+ payload: UnknownRecord,
71
+ ): LiveServerEvent | null {
72
+ const session = payload.session;
73
+ if (!isRecord(session) || typeof session.id !== "string") return null;
74
+ if (typeof session.instructions === "string") {
75
+ return { type, session: { id: session.id, instructions: session.instructions } };
76
+ }
77
+ return { type, session: { id: session.id } };
78
+ }
79
+
80
+ function parseTranscriptAddedEvent(
81
+ type: "input_transcript.added" | "output_transcript.added",
82
+ payload: UnknownRecord,
83
+ ): LiveServerEvent | null {
84
+ const item = payload.item;
85
+ if (!isRecord(item) || typeof item.text !== "string") return null;
86
+ return { type, item: { text: item.text } };
87
+ }
88
+
89
+ function parseTurnDoneEvent(payload: UnknownRecord): LiveServerEvent | null {
90
+ const turn = payload.turn;
91
+ if (!isRecord(turn) || (turn.role !== "user" && turn.role !== "assistant")) return null;
92
+ if (typeof turn.transcript !== "string") return null;
93
+ return { type: "turn.done", turn: { role: turn.role, transcript: turn.transcript } };
94
+ }
95
+
96
+ function parseDelegationCreatedEvent(payload: UnknownRecord): LiveServerEvent | null {
97
+ const item = payload.item;
98
+ if (
99
+ !isRecord(item) ||
100
+ item.type !== "delegation" ||
101
+ item.target !== "client" ||
102
+ typeof item.id !== "string"
103
+ ) {
104
+ return null;
105
+ }
106
+ if (!Array.isArray(item.content)) return null;
107
+ const content: LiveInputTextContent[] = [];
108
+ for (const candidate of item.content) {
109
+ if (
110
+ !isRecord(candidate) ||
111
+ candidate.type !== "input_text" ||
112
+ typeof candidate.text !== "string"
113
+ ) {
114
+ continue;
115
+ }
116
+ content.push({ type: "input_text", text: candidate.text });
117
+ }
118
+ return {
119
+ type: "delegation.created",
120
+ item: { type: "delegation", target: "client", id: item.id, content },
121
+ };
122
+ }
123
+
124
+ function stringifyErrorValue(value: unknown): string | null {
125
+ if (typeof value === "string") return value;
126
+ if (value === undefined) return null;
127
+ try {
128
+ return JSON.stringify(value) ?? null;
129
+ } catch {
130
+ return String(value);
131
+ }
132
+ }
133
+
134
+ function parseErrorEvent(payload: UnknownRecord): LiveServerEvent | null {
135
+ if (typeof payload.message === "string") return { type: "error", message: payload.message };
136
+ const error = payload.error;
137
+ if (isRecord(error) && typeof error.message === "string") {
138
+ return { type: "error", message: error.message };
139
+ }
140
+ const message = stringifyErrorValue(error);
141
+ return message === null ? null : { type: "error", message };
142
+ }
143
+
144
+ export function parseLiveServerEvent(payload: unknown): LiveServerEvent | null {
145
+ const parsed = parsePayload(payload);
146
+ if (!parsed || typeof parsed.type !== "string") return null;
147
+
148
+ switch (parsed.type) {
149
+ case "session.started":
150
+ case "session.updated":
151
+ return parseSessionEvent(parsed.type, parsed);
152
+ case "output_audio.delta":
153
+ return typeof parsed.audio === "string" ? { type: parsed.type, audio: parsed.audio } : null;
154
+ case "input_transcript.added":
155
+ case "output_transcript.added":
156
+ return parseTranscriptAddedEvent(parsed.type, parsed);
157
+ case "turn.done":
158
+ return parseTurnDoneEvent(parsed);
159
+ case "delegation.created":
160
+ return parseDelegationCreatedEvent(parsed);
161
+ case "error":
162
+ return parseErrorEvent(parsed);
163
+ default:
164
+ return { type: "unknown", wireType: parsed.type };
165
+ }
166
+ }
167
+
168
+ export function buildLiveSessionPayload(instructions: string, voice: string): LiveSessionPayload {
169
+ return {
170
+ model: LIVE_MODEL,
171
+ instructions,
172
+ audio: { output: { voice } },
173
+ delegation: { type: "client" },
174
+ };
175
+ }
176
+
177
+ export function buildDelegationContextAppend(
178
+ delegationItemId: string,
179
+ text: string,
180
+ channel?: LiveContextChannel,
181
+ ): LiveClientMessage {
182
+ return {
183
+ type: "delegation.context.append",
184
+ delegation_item_id: delegationItemId,
185
+ ...(channel === undefined ? {} : { channel }),
186
+ content: [{ type: "input_text", text }],
187
+ };
188
+ }
189
+
190
+ export function buildSessionContextAppend(
191
+ text: string,
192
+ channel?: LiveContextChannel,
193
+ ): LiveClientMessage {
194
+ return {
195
+ type: "session.context.append",
196
+ ...(channel === undefined ? {} : { channel }),
197
+ content: [{ type: "input_text", text }],
198
+ };
199
+ }
200
+
201
+ export function buildSessionClose(): LiveClientMessage {
202
+ return { type: "session.close" };
203
+ }
204
+
205
+ function utf8ByteLength(codePoint: number): number {
206
+ if (codePoint <= 0x7f) return 1;
207
+ if (codePoint <= 0x7ff) return 2;
208
+ if (codePoint <= 0xffff) return 3;
209
+ return 4;
210
+ }
211
+
212
+ export function chunkLiveContext(text: string): string[] {
213
+ if (text.length === 0) return [""];
214
+
215
+ const chunks: string[] = [];
216
+ let chunkStart = 0;
217
+ let chunkBytes = 0;
218
+ let index = 0;
219
+ while (index < text.length) {
220
+ const codePoint = text.codePointAt(index);
221
+ if (codePoint === undefined) break;
222
+ const characterLength = codePoint > 0xffff ? 2 : 1;
223
+ const characterBytes = utf8ByteLength(codePoint);
224
+ if (chunkBytes + characterBytes > CONTEXT_CHUNK_BYTES) {
225
+ chunks.push(text.slice(chunkStart, index));
226
+ chunkStart = index;
227
+ chunkBytes = 0;
228
+ }
229
+ chunkBytes += characterBytes;
230
+ index += characterLength;
231
+ }
232
+ chunks.push(text.slice(chunkStart));
233
+ return chunks;
234
+ }