@neuralmux/omp-superwhisper 1.0.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.
@@ -0,0 +1,356 @@
1
+ import { appendFileSync } from "node:fs"
2
+ import { basename } from "node:path"
3
+ import { $ } from "bun"
4
+ import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@oh-my-pi/pi-coding-agent"
5
+
6
+ import { LOG_PREFIX, MESSAGE_DIR } from "./constants"
7
+ import {
8
+ extractLastAssistantText,
9
+ getLastAssistant,
10
+ isEndTurn,
11
+ extractSummary,
12
+ } from "./message"
13
+ import { getHostOps, type HostOps } from "./host"
14
+
15
+ async function getGitBranch(cwd: string): Promise<string | undefined> {
16
+ try {
17
+ const result = await $`git -C ${cwd} rev-parse --abbrev-ref HEAD`.quiet()
18
+ const trimmed = result.text().trim()
19
+ return trimmed || undefined
20
+ } catch {
21
+ return undefined
22
+ }
23
+ }
24
+
25
+ export default async function superwhisperExtension(pi: ExtensionAPI): Promise<void> {
26
+ const host: HostOps = getHostOps()
27
+ const { scheme } = await host.detect()
28
+
29
+ const DEBUG = !!process.env.SUPERWHISPER_DEBUG
30
+ const LOG_FILE = `${MESSAGE_DIR}/debug.log`
31
+
32
+ function log(level: "debug" | "info" | "warn" | "error", message: string) {
33
+ if (!DEBUG) return
34
+ try {
35
+ appendFileSync(
36
+ LOG_FILE,
37
+ `[${new Date().toISOString()}] [${level}] ${LOG_PREFIX} ${message}\n`,
38
+ )
39
+ } catch {}
40
+ }
41
+
42
+ // --- Session id ---
43
+
44
+ // Derived from the session file path so it survives reloads/forks and stays
45
+ // consistent across separate runs that resume the same session. Falls back
46
+ // to a pid-based id only when a session file hasn't been bound yet.
47
+ function deriveSessionId(ctx: ExtensionContext): string {
48
+ // OMP's sessionManager may expose the session file path; fall back to pid
49
+ const sm = ctx.sessionManager as any
50
+ const file = typeof sm.getSessionFile === "function" ? sm.getSessionFile() : undefined
51
+ if (file) return basename(file).replace(/[^a-zA-Z0-9_.-]/g, "_")
52
+ return `omp-${process.pid}`
53
+ }
54
+
55
+ // --- State ---
56
+
57
+ const activePolls = new Map<string, AbortController>()
58
+
59
+ // Sessions explicitly disabled via the toggle tool / slash command.
60
+ // We cache in-process to avoid unnecessary bridge round-trips.
61
+ const disabledSessions = new Set<string>()
62
+
63
+ async function isSessionDisabled(sessionId: string): Promise<boolean> {
64
+ if (disabledSessions.has(sessionId)) return true
65
+ return host.isSessionDisabled(sessionId)
66
+ }
67
+
68
+ async function disableSession(sessionId: string): Promise<void> {
69
+ disabledSessions.add(sessionId)
70
+ try {
71
+ await host.setSessionDisabled(sessionId, true)
72
+ } catch (err) {
73
+ log("error", `Failed to set disabled flag for session=${sessionId}: ${err}`)
74
+ }
75
+ }
76
+
77
+ async function enableSession(sessionId: string): Promise<void> {
78
+ disabledSessions.delete(sessionId)
79
+ try {
80
+ await host.setSessionDisabled(sessionId, false)
81
+ } catch (err) {
82
+ log("error", `Failed to remove disabled flag for session=${sessionId}: ${err}`)
83
+ }
84
+ }
85
+
86
+ function cancelPoll(sessionId: string, source: string): boolean {
87
+ const ctrl = activePolls.get(sessionId)
88
+ if (ctrl) {
89
+ ctrl.abort()
90
+ activePolls.delete(sessionId)
91
+ log("debug", `Poll cancelled for session=${sessionId} (${source})`)
92
+ return true
93
+ }
94
+ return false
95
+ }
96
+
97
+ function sendDismiss(sessionId: string, source: string) {
98
+ log("debug", `Sending dismiss via inbox (${source}) for session=${sessionId}`)
99
+ host.deliverPayload({ kind: "dismiss", sessionId }, scheme).catch((err) => {
100
+ log("error", `Failed to send dismiss for session=${sessionId}: ${err}`)
101
+ })
102
+ }
103
+
104
+ // --- Notification ---
105
+
106
+ type NotifyOutcome =
107
+ | { kind: "response"; text: string }
108
+ | { kind: "empty" }
109
+ | { kind: "cancelled" }
110
+ | { kind: "timeout" }
111
+
112
+ async function sendNotification(params: {
113
+ sessionId: string
114
+ status: string
115
+ summary: string
116
+ messageContent: string
117
+ cwd: string
118
+ title?: string
119
+ }): Promise<NotifyOutcome> {
120
+ const { sessionId, status, summary, messageContent, cwd, title } = params
121
+
122
+ cancelPoll(sessionId, "new-notification")
123
+
124
+ try {
125
+ await host.writeMessage(sessionId, messageContent)
126
+ } catch (err) {
127
+ log("error", `Failed to write message file for session=${sessionId}: ${err}`)
128
+ return { kind: "timeout" }
129
+ }
130
+
131
+ // Remove any stale response file before sending the new notification
132
+ try {
133
+ await host.deleteResponse(sessionId)
134
+ } catch {}
135
+
136
+ const branch = await getGitBranch(cwd)
137
+ const projectName = basename(cwd) || "omp"
138
+
139
+ try {
140
+ await host.deliverPayload(
141
+ {
142
+ kind: "update",
143
+ agent: "omp",
144
+ status,
145
+ sessionId,
146
+ summary,
147
+ messageFile: `${MESSAGE_DIR}/${sessionId}-message.txt`,
148
+ responseFile: `${MESSAGE_DIR}/${sessionId}-response.txt`,
149
+ cwd,
150
+ project: projectName,
151
+ branch,
152
+ title,
153
+ hookPid: process.pid,
154
+ },
155
+ scheme,
156
+ )
157
+ } catch (err) {
158
+ log("error", `Failed to deliver Superwhisper payload — ${err}`)
159
+ return { kind: "timeout" }
160
+ }
161
+
162
+ log("info", `Notification sent: status=${status} session=${sessionId}`)
163
+
164
+ const ctrl = new AbortController()
165
+ activePolls.set(sessionId, ctrl)
166
+
167
+ const result = await host.waitForResponse(sessionId, ctrl.signal)
168
+
169
+ if (activePolls.get(sessionId) === ctrl) activePolls.delete(sessionId)
170
+
171
+ try {
172
+ await host.deleteResponse(sessionId)
173
+ await host.deleteMessage(sessionId)
174
+ } catch {}
175
+
176
+ return result
177
+ }
178
+
179
+ // --- Event handlers ---
180
+
181
+ pi.on("agent_start", async (_event, ctx: ExtensionContext) => {
182
+ // A new turn started — any prior notification poll is stale. Kill it so
183
+ // we don't re-inject an old voice response into this fresh turn.
184
+ const sessionId = deriveSessionId(ctx)
185
+ cancelPoll(sessionId, "agent_start")
186
+ })
187
+
188
+ pi.on("agent_end", async (event: any, ctx: ExtensionContext) => {
189
+ const sessionId = deriveSessionId(ctx)
190
+ const cwd = ctx.cwd
191
+
192
+ if (await isSessionDisabled(sessionId)) {
193
+ log("debug", `Skipping agent_end for session=${sessionId} (disabled)`)
194
+ return
195
+ }
196
+
197
+ const messages = event.messages ?? []
198
+ const lastAssistant = getLastAssistant(messages)
199
+ const fullMessage = extractLastAssistantText(messages)
200
+
201
+ if (!fullMessage) {
202
+ log("info", `Skipping empty completion for session=${sessionId}`)
203
+ return
204
+ }
205
+
206
+ if (!isEndTurn(lastAssistant)) {
207
+ log(
208
+ "info",
209
+ `Skipping non-end-turn agent_end for session=${sessionId} (stopReason=${lastAssistant?.stopReason})`,
210
+ )
211
+ return
212
+ }
213
+
214
+ const summary = extractSummary(fullMessage)
215
+ const title = ctx.sessionManager.getSessionName?.() as string | undefined
216
+
217
+ const outcome = await sendNotification({
218
+ sessionId,
219
+ status: "completed",
220
+ summary,
221
+ messageContent: fullMessage,
222
+ cwd,
223
+ title,
224
+ })
225
+
226
+ switch (outcome.kind) {
227
+ case "response":
228
+ try {
229
+ pi.sendUserMessage(outcome.text)
230
+ log("info", `Voice response sent back to omp for session=${sessionId}`)
231
+ } catch (err) {
232
+ log("error", `Failed to sendUserMessage: ${err}`)
233
+ }
234
+ return
235
+ case "empty":
236
+ log("info", `User dismissed notification for session=${sessionId}`)
237
+ return
238
+ case "cancelled":
239
+ log("info", `Notification cancelled for session=${sessionId}`)
240
+ return
241
+ case "timeout":
242
+ log("info", `Poll timed out for session=${sessionId}`)
243
+ sendDismiss(sessionId, "completed-timeout")
244
+ return
245
+ }
246
+ })
247
+
248
+ pi.on("session_shutdown", async (_event: any, ctx: ExtensionContext) => {
249
+ const sessionId = deriveSessionId(ctx)
250
+ log("info", `session_shutdown for session=${sessionId}`)
251
+ if (cancelPoll(sessionId, "session_shutdown")) {
252
+ sendDismiss(sessionId, "session_shutdown")
253
+ }
254
+ })
255
+
256
+ // --- Tool ---
257
+
258
+ const { z } = pi.zod
259
+
260
+ pi.registerTool({
261
+ name: "superwhisper_toggle",
262
+ label: "Superwhisper",
263
+ description:
264
+ "Enable or disable Superwhisper voice notifications for this session. " +
265
+ "Use action='disable' when the user wants to turn Superwhisper off, " +
266
+ "and action='enable' when they want to turn it back on.",
267
+ parameters: z.object({
268
+ action: z.enum(["enable", "disable"]),
269
+ }),
270
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
271
+ const sessionId = deriveSessionId(ctx)
272
+ if (params.action === "disable") {
273
+ await disableSession(sessionId)
274
+ log("info", `Superwhisper disabled for session=${sessionId}`)
275
+ return {
276
+ content: [
277
+ {
278
+ type: "text",
279
+ text: "Superwhisper voice notifications disabled for this session.",
280
+ },
281
+ ],
282
+ details: undefined,
283
+ isError: false,
284
+ }
285
+ } else {
286
+ await enableSession(sessionId)
287
+ log("info", `Superwhisper re-enabled for session=${sessionId}`)
288
+ return {
289
+ content: [
290
+ {
291
+ type: "text",
292
+ text: "Superwhisper voice notifications re-enabled for this session.",
293
+ },
294
+ ],
295
+ details: undefined,
296
+ isError: false,
297
+ }
298
+ }
299
+ },
300
+ })
301
+
302
+ // --- Slash command ---
303
+
304
+ pi.registerCommand("superwhisper", {
305
+ description: "Enable, disable, or test Superwhisper voice notifications for this session",
306
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
307
+ const sessionId = deriveSessionId(ctx)
308
+ const action = args.trim().toLowerCase()
309
+
310
+ if (action === "off" || action === "disable") {
311
+ await disableSession(sessionId)
312
+ ctx.ui.notify("Superwhisper disabled for this session", "info")
313
+ return
314
+ }
315
+ if (action === "on" || action === "enable") {
316
+ await enableSession(sessionId)
317
+ ctx.ui.notify("Superwhisper enabled for this session", "info")
318
+ return
319
+ }
320
+ if (action === "test") {
321
+ const summary = "OMP Superwhisper test"
322
+ const message = "This is an OMP Superwhisper test notification."
323
+ ctx.ui.notify("Sent Superwhisper test notification", "info")
324
+ sendNotification({
325
+ sessionId,
326
+ status: "completed",
327
+ summary,
328
+ messageContent: message,
329
+ cwd: ctx.cwd,
330
+ title: ctx.sessionManager.getSessionName?.() as string | undefined,
331
+ })
332
+ .then((outcome) => {
333
+ log("info", `Test notification outcome: ${outcome.kind}`)
334
+ if (outcome.kind === "response") {
335
+ try {
336
+ pi.sendUserMessage(outcome.text)
337
+ } catch (err) {
338
+ log("error", `Failed to sendUserMessage: ${err}`)
339
+ }
340
+ }
341
+ })
342
+ .catch((err) => log("error", `Test notification failed: ${err}`))
343
+ return
344
+ }
345
+ if (action === "" || action === "status") {
346
+ const disabled = await isSessionDisabled(sessionId)
347
+ ctx.ui.notify(
348
+ `Superwhisper is ${disabled ? "disabled" : "enabled"} for this session. Usage: /superwhisper [on|off|test|status]`,
349
+ "info",
350
+ )
351
+ return
352
+ }
353
+ ctx.ui.notify("Usage: /superwhisper [on|off|test|status]", "info")
354
+ },
355
+ })
356
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@neuralmux/omp-superwhisper",
3
+ "version": "1.0.1",
4
+ "description": "Superwhisper voice integration extension for Oh My Pi — get voice notifications when tasks complete",
5
+ "type": "module",
6
+ "keywords": [
7
+ "omp",
8
+ "oh-my-pi",
9
+ "omp-extension",
10
+ "superwhisper",
11
+ "voice",
12
+ "notifications",
13
+ "ai",
14
+ "coding-agent"
15
+ ],
16
+ "author": "Superwhisper",
17
+ "license": "MIT",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git@github.com:neuralmux/pi-superwhisper.git"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "omp": {
26
+ "extensions": ["./extensions/superwhisper.ts"]
27
+ },
28
+ "pi": {
29
+ "extensions": ["./extensions/superwhisper.ts"]
30
+ },
31
+ "files": [
32
+ "extensions",
33
+ "bin",
34
+ "README.md",
35
+ "LICENSE"
36
+ ],
37
+ "bin": {
38
+ "superwhisper-bridge": "./bin/superwhisper-bridge.ts",
39
+ "install-bridge-service": "./bin/install-bridge-service.ts"
40
+ },
41
+ "scripts": {
42
+ "typecheck": "bun run tsc --noEmit"
43
+ },
44
+ "peerDependencies": {
45
+ "@oh-my-pi/pi-coding-agent": ">=0.65.0"
46
+ },
47
+ "devDependencies": {
48
+ "@oh-my-pi/pi-coding-agent": "^0.70.6",
49
+ "@types/bun": "latest",
50
+ "typescript": "^5.7.3"
51
+ }
52
+ }