@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,237 @@
1
+ /**
2
+ * HostOps — abstraction over host-specific Superwhisper operations.
3
+ *
4
+ * Two implementations:
5
+ * DirectHostOps – runs on macOS, talks to Superwhisper via filesystem + open
6
+ * BridgeHostOps – runs inside a container, proxies to a host daemon via HTTP
7
+ *
8
+ * Selection is automatic:
9
+ * - If SUPERWHISPER_BRIDGE_URL is set → BridgeHostOps
10
+ * - Otherwise → DirectHostOps
11
+ */
12
+
13
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, unlinkSync } from "node:fs"
14
+ import { $ } from "bun"
15
+ import type { InboxPayload } from "./inbox"
16
+ import { deliverAgentPayload } from "./inbox"
17
+ import { waitForResponse, type WaitResult } from "./poll"
18
+ import { MESSAGE_DIR } from "./constants"
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Public interface
22
+ // ---------------------------------------------------------------------------
23
+
24
+ export interface HostOps {
25
+ /** Detect the Superwhisper deeplink scheme and whether the app is running. */
26
+ detect(): Promise<{ scheme: string; running: boolean }>
27
+
28
+ /** Deliver an inbox payload (update or dismiss). */
29
+ deliverPayload(payload: InboxPayload, scheme: string): Promise<boolean>
30
+
31
+ /** Write per-session message file. */
32
+ writeMessage(sessionId: string, content: string): Promise<void>
33
+
34
+ /** Read per-session message file; null if missing. */
35
+ readMessage(sessionId: string): Promise<string | null>
36
+
37
+ /** Remove per-session message file. */
38
+ deleteMessage(sessionId: string): Promise<void>
39
+
40
+ /**
41
+ * Wait for Superwhisper to write a response file.
42
+ * Returns { kind: "response", text } | { kind: "empty" } |
43
+ * { kind: "timeout" } | { kind: "cancelled" }
44
+ */
45
+ waitForResponse(sessionId: string, signal?: AbortSignal): Promise<WaitResult>
46
+
47
+ /** Remove per-session response file. */
48
+ deleteResponse(sessionId: string): Promise<void>
49
+
50
+ /** Is this session disabled for Superwhisper notifications? */
51
+ isSessionDisabled(sessionId: string): Promise<boolean>
52
+
53
+ /** Enable or disable Superwhisper for this session. */
54
+ setSessionDisabled(sessionId: string, disabled: boolean): Promise<void>
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Direct (on-host) implementation
59
+ // ---------------------------------------------------------------------------
60
+
61
+ class DirectHostOps implements HostOps {
62
+ private scheme: string | null = null
63
+
64
+ async detect(): Promise<{ scheme: string; running: boolean }> {
65
+ const envScheme = process.env.SUPERWHISPER_SCHEME
66
+ if (envScheme) {
67
+ this.scheme = envScheme
68
+ } else if (!this.scheme) {
69
+ try {
70
+ await $`pgrep -f DerivedData.*superwhisper.app`.quiet()
71
+ this.scheme = "superwhisper-debug"
72
+ } catch {
73
+ this.scheme = "superwhisper"
74
+ }
75
+ }
76
+ const running = await this.checkRunning()
77
+ return { scheme: this.scheme, running }
78
+ }
79
+
80
+ private async checkRunning(): Promise<boolean> {
81
+ try {
82
+ await $`pgrep -x superwhisper`.quiet()
83
+ return true
84
+ } catch {
85
+ return false
86
+ }
87
+ }
88
+
89
+ async deliverPayload(payload: InboxPayload, scheme: string): Promise<boolean> {
90
+ return deliverAgentPayload(payload, scheme)
91
+ }
92
+
93
+ async writeMessage(sessionId: string, content: string): Promise<void> {
94
+ mkdirSync(MESSAGE_DIR, { recursive: true })
95
+ writeFileSync(`${MESSAGE_DIR}/${sessionId}-message.txt`, content)
96
+ }
97
+
98
+ async readMessage(sessionId: string): Promise<string | null> {
99
+ try {
100
+ return readFileSync(`${MESSAGE_DIR}/${sessionId}-message.txt`, "utf8")
101
+ } catch {
102
+ return null
103
+ }
104
+ }
105
+
106
+ async deleteMessage(sessionId: string): Promise<void> {
107
+ try { unlinkSync(`${MESSAGE_DIR}/${sessionId}-message.txt`) } catch {}
108
+ }
109
+
110
+ async waitForResponse(sessionId: string, signal?: AbortSignal): Promise<WaitResult> {
111
+ const path = `${MESSAGE_DIR}/${sessionId}-response.txt`
112
+ return waitForResponse(path, { signal })
113
+ }
114
+
115
+ async deleteResponse(sessionId: string): Promise<void> {
116
+ try { unlinkSync(`${MESSAGE_DIR}/${sessionId}-response.txt`) } catch {}
117
+ }
118
+
119
+ async isSessionDisabled(sessionId: string): Promise<boolean> {
120
+ return existsSync(`${MESSAGE_DIR}/disabled-${sessionId}`)
121
+ }
122
+
123
+ async setSessionDisabled(sessionId: string, disabled: boolean): Promise<void> {
124
+ mkdirSync(MESSAGE_DIR, { recursive: true })
125
+ const flag = `${MESSAGE_DIR}/disabled-${sessionId}`
126
+ if (disabled) {
127
+ writeFileSync(flag, "")
128
+ } else {
129
+ try { unlinkSync(flag) } catch {}
130
+ }
131
+ }
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Bridge (container → host daemon) implementation
136
+ // ---------------------------------------------------------------------------
137
+
138
+ class BridgeHostOps implements HostOps {
139
+ private url: string
140
+ private cachedScheme: string | null = null
141
+
142
+ constructor(bridgeUrl: string) {
143
+ this.url = bridgeUrl.replace(/\/+$/, "")
144
+ }
145
+
146
+ async detect(): Promise<{ scheme: string; running: boolean }> {
147
+ const res = await fetch(`${this.url}/health`)
148
+ const data = await res.json() as { scheme: string; running: boolean }
149
+ this.cachedScheme = data.scheme
150
+ return { scheme: data.scheme, running: data.running }
151
+ }
152
+
153
+ async deliverPayload(payload: InboxPayload, _scheme: string): Promise<boolean> {
154
+ const res = await fetch(`${this.url}/inbox`, {
155
+ method: "POST",
156
+ headers: { "content-type": "application/json" },
157
+ body: JSON.stringify(payload),
158
+ })
159
+ const data = await res.json() as { ok: boolean }
160
+ return data.ok === true
161
+ }
162
+
163
+ async writeMessage(sessionId: string, content: string): Promise<void> {
164
+ await fetch(`${this.url}/session/${encodeURIComponent(sessionId)}/message`, {
165
+ method: "PUT",
166
+ headers: { "content-type": "text/plain" },
167
+ body: content,
168
+ })
169
+ }
170
+
171
+ async readMessage(sessionId: string): Promise<string | null> {
172
+ const res = await fetch(
173
+ `${this.url}/session/${encodeURIComponent(sessionId)}/message`,
174
+ )
175
+ return res.status === 200 ? await res.text() : null
176
+ }
177
+
178
+ async deleteMessage(sessionId: string): Promise<void> {
179
+ await fetch(`${this.url}/session/${encodeURIComponent(sessionId)}/message`, {
180
+ method: "DELETE",
181
+ })
182
+ }
183
+
184
+ async waitForResponse(sessionId: string, signal?: AbortSignal): Promise<WaitResult> {
185
+ const res = await fetch(
186
+ `${this.url}/session/${encodeURIComponent(sessionId)}/response?timeout=1800000`,
187
+ { signal },
188
+ )
189
+ return (await res.json()) as WaitResult
190
+ }
191
+
192
+ async deleteResponse(sessionId: string): Promise<void> {
193
+ await fetch(`${this.url}/session/${encodeURIComponent(sessionId)}/response`, {
194
+ method: "DELETE",
195
+ })
196
+ }
197
+
198
+ async isSessionDisabled(sessionId: string): Promise<boolean> {
199
+ const res = await fetch(
200
+ `${this.url}/session/${encodeURIComponent(sessionId)}/disabled`,
201
+ )
202
+ const data = await res.json() as { disabled: boolean }
203
+ return data.disabled === true
204
+ }
205
+
206
+ async setSessionDisabled(sessionId: string, disabled: boolean): Promise<void> {
207
+ await fetch(
208
+ `${this.url}/session/${encodeURIComponent(sessionId)}/disabled`,
209
+ { method: disabled ? "PUT" : "DELETE" },
210
+ )
211
+ }
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Factory
216
+ // ---------------------------------------------------------------------------
217
+
218
+ let _hostOps: HostOps | null = null
219
+
220
+ export function createHostOps(): HostOps {
221
+ const bridgeUrl = process.env.SUPERWHISPER_BRIDGE_URL
222
+ if (bridgeUrl) {
223
+ return new BridgeHostOps(bridgeUrl)
224
+ }
225
+ return new DirectHostOps()
226
+ }
227
+
228
+ /** Singleton accessor — creates on first call, returns cached thereafter. */
229
+ export function getHostOps(): HostOps {
230
+ if (!_hostOps) _hostOps = createHostOps()
231
+ return _hostOps
232
+ }
233
+
234
+ /** Reset singleton (useful for testing). */
235
+ export function __resetHostOpsForTest(): void {
236
+ _hostOps = null
237
+ }
@@ -0,0 +1,83 @@
1
+ import { mkdirSync, writeFileSync, renameSync, unlinkSync } from "node:fs"
2
+ import { homedir } from "node:os"
3
+ import { join } from "node:path"
4
+ import { $ } from "bun"
5
+
6
+ export interface InboxPayload {
7
+ kind: "update" | "dismiss"
8
+ sessionId?: string
9
+ requestId?: string
10
+ agent?: string
11
+ status?: string
12
+ summary?: string
13
+ message?: string
14
+ messageFile?: string
15
+ responseFile?: string
16
+ cwd?: string
17
+ project?: string
18
+ branch?: string
19
+ title?: string
20
+ hookPid?: number
21
+ }
22
+
23
+ let INBOX_DIR = join(
24
+ homedir(),
25
+ "Library/Application Support/superwhisper/agent/inbox",
26
+ )
27
+
28
+ export function __setInboxDirForTest(dir: string): void {
29
+ INBOX_DIR = dir
30
+ }
31
+
32
+ export function writeInboxPayload(payload: InboxPayload): boolean {
33
+ try {
34
+ mkdirSync(INBOX_DIR, { recursive: true })
35
+ } catch {
36
+ return false
37
+ }
38
+
39
+ const id = crypto.randomUUID()
40
+ const tmpPath = join(INBOX_DIR, `${id}.json.tmp`)
41
+ const finalPath = join(INBOX_DIR, `${id}.json`)
42
+
43
+ try {
44
+ writeFileSync(tmpPath, JSON.stringify(payload))
45
+ renameSync(tmpPath, finalPath)
46
+ return true
47
+ } catch {
48
+ try {
49
+ unlinkSync(tmpPath)
50
+ } catch {}
51
+ return false
52
+ }
53
+ }
54
+
55
+ export async function isSuperwhisperRunning(): Promise<boolean> {
56
+ try {
57
+ await $`pgrep -x superwhisper`.quiet()
58
+ return true
59
+ } catch {
60
+ return false
61
+ }
62
+ }
63
+
64
+ export async function fireAgentWake(scheme: string): Promise<void> {
65
+ const url = `${scheme}://agent-wake`
66
+ try {
67
+ await $`open ${url}`.quiet()
68
+ } catch {
69
+ // wake is best-effort
70
+ }
71
+ }
72
+
73
+ export async function deliverAgentPayload(
74
+ payload: InboxPayload,
75
+ scheme: string,
76
+ ): Promise<boolean> {
77
+ const wrote = writeInboxPayload(payload)
78
+ const running = await isSuperwhisperRunning()
79
+ if (!running) {
80
+ await fireAgentWake(scheme)
81
+ }
82
+ return wrote
83
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Extract the last assistant text from an agent_end message list.
3
+ * Returns "" if no assistant message or no text content.
4
+ */
5
+ export function extractLastAssistantText(messages: any[]): string {
6
+ for (let i = messages.length - 1; i >= 0; i--) {
7
+ const m = messages[i]
8
+ if (m?.role !== "assistant") continue
9
+ const content = Array.isArray(m.content) ? m.content : []
10
+ const text = content
11
+ .filter((c: any) => c?.type === "text")
12
+ .map((c: any) => c.text || "")
13
+ .join("\n")
14
+ .trim()
15
+ if (text) return text
16
+ return ""
17
+ }
18
+ return ""
19
+ }
20
+
21
+ /**
22
+ * The last assistant message — used to inspect stopReason / tool calls.
23
+ */
24
+ export function getLastAssistant(messages: any[]): any | undefined {
25
+ for (let i = messages.length - 1; i >= 0; i--) {
26
+ const m = messages[i]
27
+ if (m?.role === "assistant") return m
28
+ }
29
+ return undefined
30
+ }
31
+
32
+ /**
33
+ * Determine if the turn truly ended (vs. the model wanting to call more tools).
34
+ * OMP marks completed turns with stopReason "stop"; "toolUse" means the loop
35
+ * is about to run tools and isn't actually done.
36
+ */
37
+ export function isEndTurn(message: any | undefined): boolean {
38
+ if (!message) return false
39
+ return message.stopReason === "stop"
40
+ }
41
+
42
+ export function extractSummary(text: string): string {
43
+ if (!text) return ""
44
+
45
+ const maxLength = 200
46
+
47
+ if (text.length <= maxLength) return text
48
+
49
+ const sentenceEnd = text.substring(0, maxLength).lastIndexOf(". ")
50
+ if (sentenceEnd > 100) return text.substring(0, sentenceEnd + 1)
51
+
52
+ const wordEnd = text.substring(0, maxLength).lastIndexOf(" ")
53
+ if (wordEnd > 150) return text.substring(0, wordEnd) + "..."
54
+
55
+ return text.substring(0, maxLength) + "..."
56
+ }
@@ -0,0 +1,115 @@
1
+ import { existsSync, readFileSync, watch } from "node:fs"
2
+ import { dirname, basename } from "node:path"
3
+ import { POLL_INTERVAL_MS, POLL_TIMEOUT_MS } from "./constants"
4
+
5
+ export type WaitResult =
6
+ | { kind: "response"; text: string }
7
+ | { kind: "empty" }
8
+ | { kind: "cancelled" }
9
+ | { kind: "timeout" }
10
+
11
+ export interface WaitOptions {
12
+ timeoutMs?: number
13
+ intervalMs?: number
14
+ signal?: AbortSignal
15
+ }
16
+
17
+ const EMPTY_GRACE_MS = 2_000
18
+
19
+ /**
20
+ * Wait for a response file to appear and contain text. Uses `fs.watch` on the
21
+ * parent directory so it reacts within milliseconds, with a periodic fallback
22
+ * for filesystems where watch events are flaky.
23
+ *
24
+ * - File missing for the full timeout → `timeout`
25
+ * - File created and stays empty for EMPTY_GRACE_MS → `empty` (Superwhisper X / double-ESC)
26
+ * - File created with text → `response`
27
+ * - `signal` aborts → `cancelled`
28
+ *
29
+ * The grace period prevents a race where Superwhisper creates an empty
30
+ * placeholder file before writing the transcription.
31
+ */
32
+ export function waitForResponse(
33
+ path: string,
34
+ options: WaitOptions = {},
35
+ ): Promise<WaitResult> {
36
+ const timeoutMs = options.timeoutMs ?? POLL_TIMEOUT_MS
37
+ const intervalMs = options.intervalMs ?? POLL_INTERVAL_MS
38
+ const { signal } = options
39
+
40
+ const dir = dirname(path)
41
+ const file = basename(path)
42
+
43
+ function tryReadText(): string | null {
44
+ try {
45
+ if (!existsSync(path)) return null
46
+ const raw = readFileSync(path, "utf8")
47
+ return raw
48
+ } catch {
49
+ return null
50
+ }
51
+ }
52
+
53
+ return new Promise<WaitResult>((resolve) => {
54
+ if (signal?.aborted) {
55
+ resolve({ kind: "cancelled" })
56
+ return
57
+ }
58
+
59
+ let settled = false
60
+ let watcher: ReturnType<typeof watch> | undefined
61
+ let intervalId: ReturnType<typeof setInterval> | undefined
62
+ let timeoutId: ReturnType<typeof setTimeout> | undefined
63
+ let abortHandler: (() => void) | undefined
64
+ let emptySeenAt: number | null = null
65
+
66
+ const finish = (result: WaitResult) => {
67
+ if (settled) return
68
+ settled = true
69
+ try {
70
+ watcher?.close()
71
+ } catch {}
72
+ if (intervalId) clearInterval(intervalId)
73
+ if (timeoutId) clearTimeout(timeoutId)
74
+ if (abortHandler && signal) signal.removeEventListener("abort", abortHandler)
75
+ resolve(result)
76
+ }
77
+
78
+ const check = () => {
79
+ if (settled) return
80
+ const text = tryReadText()
81
+ if (text === null) {
82
+ emptySeenAt = null
83
+ return
84
+ }
85
+ const trimmed = text.trim()
86
+ if (trimmed.length > 0) {
87
+ finish({ kind: "response", text: trimmed })
88
+ return
89
+ }
90
+ if (emptySeenAt === null) {
91
+ emptySeenAt = Date.now()
92
+ } else if (Date.now() - emptySeenAt >= EMPTY_GRACE_MS) {
93
+ finish({ kind: "empty" })
94
+ }
95
+ }
96
+
97
+ check()
98
+
99
+ try {
100
+ watcher = watch(dir, { persistent: false }, (_eventType, filename) => {
101
+ if (filename === file) check()
102
+ })
103
+ } catch {
104
+ // dir missing or watch unsupported — interval is enough
105
+ }
106
+
107
+ intervalId = setInterval(check, intervalMs)
108
+ timeoutId = setTimeout(() => finish({ kind: "timeout" }), timeoutMs)
109
+
110
+ if (signal) {
111
+ abortHandler = () => finish({ kind: "cancelled" })
112
+ signal.addEventListener("abort", abortHandler, { once: true })
113
+ }
114
+ })
115
+ }