@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.
- package/README.md +205 -0
- package/bin/com.superwhisper.bridge.plist +28 -0
- package/bin/install-bridge-service.ts +140 -0
- package/bin/superwhisper-bridge.ts +287 -0
- package/extensions/constants.ts +4 -0
- package/extensions/host.ts +237 -0
- package/extensions/inbox.ts +83 -0
- package/extensions/message.ts +56 -0
- package/extensions/poll.ts +115 -0
- package/extensions/superwhisper.ts +356 -0
- package/package.json +52 -0
|
@@ -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
|
+
}
|