@hasna/recordings 0.1.10 → 0.1.12

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.
Files changed (71) hide show
  1. package/README.md +2 -0
  2. package/dist/cli/index.js +342 -153
  3. package/dist/db/database.d.ts.map +1 -1
  4. package/dist/db/recordings.d.ts.map +1 -1
  5. package/dist/index.js +114 -33
  6. package/dist/lib/config.d.ts.map +1 -1
  7. package/dist/lib/enhancer.d.ts +2 -2
  8. package/dist/lib/enhancer.d.ts.map +1 -1
  9. package/dist/lib/recorder.d.ts.map +1 -1
  10. package/dist/lib/transcriber.d.ts +8 -2
  11. package/dist/lib/transcriber.d.ts.map +1 -1
  12. package/dist/mcp/index.js +183 -52
  13. package/dist/types/index.d.ts +3 -0
  14. package/dist/types/index.d.ts.map +1 -1
  15. package/dist/version.d.ts +2 -0
  16. package/dist/version.d.ts.map +1 -0
  17. package/package.json +20 -3
  18. package/scripts/install_macos_app.sh +76 -0
  19. package/src/native/Recordings/{Recordings → App}/RecordingsApp.swift +16 -4
  20. package/src/native/Recordings/Package.resolved +19 -1
  21. package/src/native/Recordings/Package.swift +16 -5
  22. package/src/native/Recordings/RecordingsLib/FnKeyMonitor.swift +131 -0
  23. package/src/native/Recordings/{Recordings → RecordingsLib}/Info.plist +6 -0
  24. package/src/native/Recordings/RecordingsLib/MenuBarPopover.swift +361 -0
  25. package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +164 -0
  26. package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +113 -0
  27. package/src/native/Recordings/RecordingsLib/ProjectStore.swift +126 -0
  28. package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +383 -0
  29. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +724 -0
  30. package/src/native/Recordings/RecordingsLib/SettingsView.swift +218 -0
  31. package/src/native/Recordings/{Recordings → RecordingsLib}/VoiceShortcuts.swift +12 -8
  32. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +63 -0
  33. package/src/native/Recordings/RecordingsTests/NativePCMRecorderTests.swift +33 -0
  34. package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +92 -0
  35. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +58 -0
  36. package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +113 -0
  37. package/src/native/Recordings/build.sh +6 -6
  38. package/bun.lock +0 -250
  39. package/bunfig.toml +0 -2
  40. package/src/__tests__/agents.test.ts +0 -136
  41. package/src/__tests__/config.test.ts +0 -252
  42. package/src/__tests__/database.test.ts +0 -167
  43. package/src/__tests__/enhancer.test.ts +0 -574
  44. package/src/__tests__/preload.ts +0 -4
  45. package/src/__tests__/projects.test.ts +0 -109
  46. package/src/__tests__/recorder.test.ts +0 -278
  47. package/src/__tests__/recordings.test.ts +0 -353
  48. package/src/__tests__/transcriber.test.ts +0 -322
  49. package/src/__tests__/types.test.ts +0 -75
  50. package/src/cli/index.ts +0 -1115
  51. package/src/db/agents.ts +0 -104
  52. package/src/db/database.ts +0 -163
  53. package/src/db/pg-migrations.ts +0 -82
  54. package/src/db/projects.ts +0 -71
  55. package/src/db/recordings.ts +0 -225
  56. package/src/index.ts +0 -81
  57. package/src/lib/config.ts +0 -183
  58. package/src/lib/enhancer.ts +0 -167
  59. package/src/lib/recorder.ts +0 -198
  60. package/src/lib/transcriber.ts +0 -105
  61. package/src/mcp/index.ts +0 -464
  62. package/src/native/Recordings/Recordings/FnKeyMonitor.swift +0 -122
  63. package/src/native/Recordings/Recordings/MenuBarPopover.swift +0 -188
  64. package/src/native/Recordings/Recordings/RecordingEngine.swift +0 -355
  65. package/src/native/Recordings/Recordings/SettingsView.swift +0 -93
  66. package/src/native/Recordings/test_fn.swift +0 -79
  67. package/src/native/Recordings/test_fn2.swift +0 -33
  68. package/src/native/RecordingsHelper.swift +0 -395
  69. package/src/types/index.ts +0 -144
  70. package/tsconfig.json +0 -21
  71. /package/src/native/Recordings/{Recordings → RecordingsLib}/Recordings.entitlements +0 -0
@@ -1,105 +0,0 @@
1
- import OpenAI from "openai";
2
- import { createReadStream } from "fs";
3
- import type { RecordingsConfig, TranscriptionResult } from "../types/index.js";
4
- import { TranscriptionError } from "../types/index.js";
5
-
6
- let _client: OpenAI | null = null;
7
-
8
- function getClient(config: RecordingsConfig): OpenAI {
9
- if (_client) return _client;
10
- if (!config.openai_api_key) {
11
- throw new TranscriptionError(
12
- "OpenAI API key not configured. Set OPENAI_API_KEY env var or add to ~/.secrets"
13
- );
14
- }
15
- _client = new OpenAI({ apiKey: config.openai_api_key });
16
- return _client;
17
- }
18
-
19
- export function resetClient(): void {
20
- _client = null;
21
- }
22
-
23
- export async function transcribeAudio(
24
- audioPath: string,
25
- config: RecordingsConfig
26
- ): Promise<TranscriptionResult> {
27
- const client = getClient(config);
28
- const startTime = Date.now();
29
-
30
- try {
31
- const transcription = await client.audio.transcriptions.create({
32
- file: createReadStream(audioPath),
33
- model: config.transcription_model,
34
- language: config.language || undefined,
35
- response_format: "json",
36
- });
37
-
38
- const durationMs = Date.now() - startTime;
39
-
40
- return {
41
- text: transcription.text,
42
- duration_ms: durationMs,
43
- model: config.transcription_model,
44
- language: (transcription as unknown as Record<string, unknown>).language as string | null,
45
- };
46
- } catch (error) {
47
- const msg = error instanceof Error ? error.message : String(error);
48
- throw new TranscriptionError(`Transcription failed: ${msg}`);
49
- }
50
- }
51
-
52
- export async function transcribeBuffer(
53
- buffer: Buffer,
54
- filename: string,
55
- config: RecordingsConfig
56
- ): Promise<TranscriptionResult> {
57
- const client = getClient(config);
58
- const startTime = Date.now();
59
-
60
- try {
61
- const file = new File([new Uint8Array(buffer)], filename, {
62
- type: getMimeType(filename),
63
- });
64
-
65
- const transcription = await client.audio.transcriptions.create({
66
- file,
67
- model: config.transcription_model,
68
- language: config.language || undefined,
69
- response_format: "json",
70
- });
71
-
72
- const durationMs = Date.now() - startTime;
73
-
74
- return {
75
- text: transcription.text,
76
- duration_ms: durationMs,
77
- model: config.transcription_model,
78
- language: (transcription as unknown as Record<string, unknown>).language as string | null,
79
- };
80
- } catch (error) {
81
- const msg = error instanceof Error ? error.message : String(error);
82
- throw new TranscriptionError(`Transcription failed: ${msg}`);
83
- }
84
- }
85
-
86
- function getMimeType(filename: string): string {
87
- const ext = filename.split(".").pop()?.toLowerCase();
88
- switch (ext) {
89
- case "wav":
90
- return "audio/wav";
91
- case "mp3":
92
- return "audio/mpeg";
93
- case "m4a":
94
- return "audio/mp4";
95
- case "webm":
96
- return "audio/webm";
97
- case "mp4":
98
- return "audio/mp4";
99
- case "mpeg":
100
- case "mpga":
101
- return "audio/mpeg";
102
- default:
103
- return "audio/wav";
104
- }
105
- }
package/src/mcp/index.ts DELETED
@@ -1,464 +0,0 @@
1
- #!/usr/bin/env bun
2
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
- import { registerCloudTools } from "@hasna/cloud";
5
- import { z } from "zod";
6
- import { loadConfig, ensureDataDir } from "../lib/config.js";
7
- import { getDatabase, getAdapter } from "../db/database.js";
8
- import {
9
- createRecording,
10
- getRecording,
11
- listRecordings,
12
- deleteRecording,
13
- searchRecordings,
14
- getRecordingStats,
15
- } from "../db/recordings.js";
16
- import { registerAgent, getAgent, listAgents, heartbeatAgent, setAgentFocus } from "../db/agents.js";
17
- import {
18
- registerProject,
19
- getProject,
20
- listProjects,
21
- } from "../db/projects.js";
22
- import { transcribeAudio, transcribeBuffer } from "../lib/transcriber.js";
23
- import { processText, needsEnhancement } from "../lib/enhancer.js";
24
- import type { Recording, RecordingFilter } from "../types/index.js";
25
-
26
- // ── Initialize ──────────────────────────────────────────────────────────────
27
-
28
- const config = loadConfig();
29
- ensureDataDir(config);
30
- getDatabase(config.db_path);
31
-
32
- const server = new McpServer({
33
- name: "recordings",
34
- version: "0.0.3",
35
- });
36
-
37
- // ── Helpers ─────────────────────────────────────────────────────────────────
38
-
39
- function text(content: string) {
40
- return { content: [{ type: "text" as const, text: content }] };
41
- }
42
-
43
- function errorResult(e: unknown) {
44
- const msg = e instanceof Error ? e.message : String(e);
45
- return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
46
- }
47
-
48
- function compact(r: Recording): string {
49
- const t = (r.processed_text || r.raw_text).slice(0, 80);
50
- return `${r.id.slice(0, 8)} | ${r.processing_mode} | ${r.created_at.slice(0, 16)} | ${t}${t.length >= 80 ? "..." : ""}`;
51
- }
52
-
53
- function full(r: Recording): string {
54
- const lines: string[] = [`ID: ${r.id}`, `Mode: ${r.processing_mode}`, `Model: ${r.model_used}`];
55
- if (r.enhancement_model) lines.push(`Enhanced by: ${r.enhancement_model}`);
56
- if (r.duration_ms) lines.push(`Duration: ${(r.duration_ms / 1000).toFixed(1)}s`);
57
- if (r.language) lines.push(`Language: ${r.language}`);
58
- if (r.tags.length > 0) lines.push(`Tags: ${r.tags.join(", ")}`);
59
- if (r.agent_id) lines.push(`Agent: ${r.agent_id}`);
60
- if (r.project_id) lines.push(`Project: ${r.project_id}`);
61
- if (r.session_id) lines.push(`Session: ${r.session_id}`);
62
- lines.push(`Created: ${r.created_at}`);
63
- lines.push(`Text: ${r.raw_text}`);
64
- if (r.processed_text && r.processed_text !== r.raw_text) {
65
- lines.push(`Enhanced: ${r.processed_text}`);
66
- }
67
- return lines.join("\n");
68
- }
69
-
70
- // ── Full tool schemas for describe_tool ─────────────────────────────────────
71
-
72
- const toolDocs: Record<string, string> = {
73
- transcribe_audio: "Transcribe audio file. Auto-enhances if needed.\nParams: audio_path (string, required): path to wav/mp3/m4a/webm | language (string): ISO code e.g. en/es/fr | no_enhance (bool): skip AI enhancement | tags (string[]): tags | agent_id (string) | project_id (string) | session_id (string)",
74
- save_recording: "Save text as recording. Auto-enhances if needed.\nParams: text (string, required): text to save | enhance (bool): force enhancement | tags (string[]) | agent_id (string) | project_id (string) | session_id (string) | metadata (object)",
75
- get_recording: "Get recording by ID or prefix.\nParams: id (string, required): recording ID or prefix",
76
- list_recordings: "List recordings, compact by default, most recent first.\nParams: limit (number, default 10) | offset (number) | processing_mode ('raw'|'enhanced') | tags (string[]) | search (string): text search | since/until (ISO date) | agent_id | project_id | session_id | full (bool): verbose output",
77
- search_recordings: "Search recordings by text content.\nParams: query (string, required) | limit (number, default 10) | agent_id | project_id | full (bool): verbose output",
78
- delete_recording: "Delete recording by ID.\nParams: id (string, required)",
79
- recording_stats: "Recording count, mode breakdown, duration.\nParams: none",
80
- detect_enhancement: "Check if text needs AI enhancement.\nParams: text (string, required)",
81
- register_agent: "Register agent (idempotent). Auto-updates last_seen_at on re-register.\nParams: name (string, required) | description (string) | role (string)",
82
- list_agents: "List registered agents.\nParams: none",
83
- get_agent: "Get agent by ID or name.\nParams: id (string, required)",
84
- heartbeat: "Update last_seen_at to signal agent is active.\nParams: agent_id (string, required): agent ID or name",
85
- set_focus: "Set active project context for this agent session.\nParams: agent_id (string, required) | project_id (string, nullable): project ID or null to clear",
86
- register_project: "Register project (idempotent).\nParams: name (string, required) | path (string, required): absolute path | description (string)",
87
- list_projects: "List registered projects.\nParams: none",
88
- };
89
-
90
- // ── Meta Tool ───────────────────────────────────────────────────────────────
91
-
92
- server.tool(
93
- "describe_tool",
94
- "Get full param docs for any tool.",
95
- { name: z.string() },
96
- async (args) => {
97
- const doc = toolDocs[args.name];
98
- return doc ? text(doc) : text(`Unknown tool: ${args.name}. Available: ${Object.keys(toolDocs).join(", ")}`);
99
- }
100
- );
101
-
102
- // ── Recording Tools (lean stubs — no param descriptions) ────────────────────
103
-
104
- server.tool(
105
- "transcribe_audio",
106
- "Transcribe audio file. Auto-enhances if needed.",
107
- {
108
- audio_path: z.string(),
109
- language: z.string().optional(),
110
- no_enhance: z.boolean().optional(),
111
- tags: z.array(z.string()).optional(),
112
- agent_id: z.string().optional(),
113
- project_id: z.string().optional(),
114
- session_id: z.string().optional(),
115
- },
116
- async (args) => {
117
- try {
118
- const cfg = { ...config };
119
- if (args.language) cfg.language = args.language;
120
- if (args.no_enhance) cfg.auto_enhance = false;
121
-
122
- const transcription = await transcribeAudio(args.audio_path, cfg);
123
- const processed = await processText(transcription.text, cfg);
124
-
125
- const recording = createRecording({
126
- audio_path: args.audio_path,
127
- raw_text: transcription.text,
128
- processed_text: processed.mode === "enhanced" ? processed.text : undefined,
129
- processing_mode: processed.mode,
130
- model_used: transcription.model,
131
- enhancement_model: processed.enhancement_model || undefined,
132
- duration_ms: transcription.duration_ms,
133
- language: transcription.language || undefined,
134
- tags: args.tags,
135
- agent_id: args.agent_id,
136
- project_id: args.project_id,
137
- session_id: args.session_id,
138
- });
139
-
140
- const output = processed.mode === "enhanced" ? processed.text : transcription.text;
141
- return text(`${recording.id.slice(0, 8)} | ${processed.mode} | ${output}`);
142
- } catch (e) {
143
- return errorResult(e);
144
- }
145
- }
146
- );
147
-
148
- server.tool(
149
- "save_recording",
150
- "Save text as recording. Auto-enhances if needed.",
151
- {
152
- text: z.string(),
153
- enhance: z.boolean().optional(),
154
- tags: z.array(z.string()).optional(),
155
- agent_id: z.string().optional(),
156
- project_id: z.string().optional(),
157
- session_id: z.string().optional(),
158
- goal: z.string().optional().describe("Goal or purpose of this recording session (e.g. 'code review for PR #123')"),
159
- role: z.string().optional().describe("Agent role for this session (e.g. 'dev agent for connectdev')"),
160
- task_list_id: z.string().optional().describe("Task list ID to bind this recording to"),
161
- metadata: z.record(z.unknown()).optional(),
162
- },
163
- async (args) => {
164
- try {
165
- let processedText: string | undefined;
166
- let mode: "raw" | "enhanced" = "raw";
167
- let enhModel: string | undefined;
168
-
169
- if (args.enhance !== false) {
170
- const processed = await processText(args.text, config);
171
- if (processed.mode === "enhanced") {
172
- processedText = processed.text;
173
- mode = "enhanced";
174
- enhModel = processed.enhancement_model || undefined;
175
- }
176
- }
177
-
178
- const recording = createRecording({
179
- raw_text: args.text,
180
- processed_text: processedText,
181
- processing_mode: mode,
182
- model_used: "direct-input",
183
- enhancement_model: enhModel,
184
- tags: args.tags,
185
- agent_id: args.agent_id,
186
- project_id: args.project_id,
187
- session_id: args.session_id,
188
- goal: args.goal,
189
- role: args.role,
190
- task_list_id: args.task_list_id,
191
- metadata: args.metadata,
192
- });
193
-
194
- const output = processedText || args.text;
195
- return text(`${recording.id.slice(0, 8)} | ${mode} | ${output}`);
196
- } catch (e) {
197
- return errorResult(e);
198
- }
199
- }
200
- );
201
-
202
- server.tool(
203
- "get_recording",
204
- "Get recording by ID or prefix.",
205
- { id: z.string() },
206
- async (args) => {
207
- try {
208
- const r = getRecording(args.id);
209
- if (!r) return text(`Not found: ${args.id}`);
210
- return text(full(r));
211
- } catch (e) {
212
- return errorResult(e);
213
- }
214
- }
215
- );
216
-
217
- server.tool(
218
- "list_recordings",
219
- "List recordings. Compact default, recent first.",
220
- {
221
- limit: z.number().optional(),
222
- offset: z.number().optional(),
223
- processing_mode: z.enum(["raw", "enhanced"]).optional(),
224
- tags: z.array(z.string()).optional(),
225
- search: z.string().optional(),
226
- since: z.string().optional(),
227
- until: z.string().optional(),
228
- agent_id: z.string().optional(),
229
- project_id: z.string().optional(),
230
- session_id: z.string().optional(),
231
- full: z.boolean().optional(),
232
- },
233
- async (args) => {
234
- try {
235
- const filter: RecordingFilter = {
236
- limit: args.limit || 10,
237
- offset: args.offset,
238
- processing_mode: args.processing_mode,
239
- tags: args.tags,
240
- search: args.search,
241
- since: args.since,
242
- until: args.until,
243
- agent_id: args.agent_id,
244
- project_id: args.project_id,
245
- session_id: args.session_id,
246
- };
247
-
248
- const recordings = listRecordings(filter);
249
- if (recordings.length === 0) return text("No recordings found.");
250
-
251
- const fmt = args.full ? full : compact;
252
- const sep = args.full ? "\n---\n" : "\n";
253
- return text(`${recordings.length} recording(s):${sep}${recordings.map(fmt).join(sep)}`);
254
- } catch (e) {
255
- return errorResult(e);
256
- }
257
- }
258
- );
259
-
260
- server.tool(
261
- "search_recordings",
262
- "Search recordings by text.",
263
- {
264
- query: z.string(),
265
- limit: z.number().optional(),
266
- agent_id: z.string().optional(),
267
- project_id: z.string().optional(),
268
- full: z.boolean().optional(),
269
- },
270
- async (args) => {
271
- try {
272
- const results = searchRecordings(args.query, {
273
- limit: args.limit || 10,
274
- agent_id: args.agent_id,
275
- project_id: args.project_id,
276
- });
277
-
278
- if (results.length === 0) return text("No results.");
279
-
280
- const fmt = args.full ? full : compact;
281
- const sep = args.full ? "\n---\n" : "\n";
282
- return text(`${results.length} result(s):${sep}${results.map(fmt).join(sep)}`);
283
- } catch (e) {
284
- return errorResult(e);
285
- }
286
- }
287
- );
288
-
289
- server.tool(
290
- "delete_recording",
291
- "Delete recording by ID.",
292
- { id: z.string() },
293
- async (args) => {
294
- try {
295
- return text(deleteRecording(args.id) ? `Deleted ${args.id}` : `Not found: ${args.id}`);
296
- } catch (e) {
297
- return errorResult(e);
298
- }
299
- }
300
- );
301
-
302
- server.tool(
303
- "recording_stats",
304
- "Recording stats: count, modes, duration.",
305
- {},
306
- async () => {
307
- try {
308
- const s = getRecordingStats();
309
- let out = `Total: ${s.total} | Raw: ${s.raw} | Enhanced: ${s.enhanced} | Duration: ${(s.total_duration_ms / 1000).toFixed(1)}s`;
310
- if (Object.keys(s.by_model).length > 0) {
311
- out += "\n" + Object.entries(s.by_model).map(([m, c]) => `${m}: ${c}`).join(", ");
312
- }
313
- return text(out);
314
- } catch (e) {
315
- return errorResult(e);
316
- }
317
- }
318
- );
319
-
320
- server.tool(
321
- "detect_enhancement",
322
- "Check if text needs AI enhancement.",
323
- { text: z.string() },
324
- async (args) => {
325
- try {
326
- const r = needsEnhancement(args.text, config);
327
- return text(`${r.needs ? "Yes" : "No"}: ${r.reason}`);
328
- } catch (e) {
329
- return errorResult(e);
330
- }
331
- }
332
- );
333
-
334
- // ── Agent Tools ─────────────────────────────────────────────────────────────
335
-
336
- server.tool(
337
- "register_agent",
338
- "Register agent (idempotent).",
339
- { name: z.string(), description: z.string().optional(), role: z.string().optional() },
340
- async (args) => {
341
- try {
342
- const a = registerAgent(args.name, args.description, args.role);
343
- return text(`${a.id} | ${a.name} | ${a.role}`);
344
- } catch (e) {
345
- return errorResult(e);
346
- }
347
- }
348
- );
349
-
350
- server.tool(
351
- "list_agents",
352
- "List registered agents.",
353
- {},
354
- async () => {
355
- try {
356
- const agents = listAgents();
357
- if (agents.length === 0) return text("None.");
358
- return text(agents.map((a) => `${a.id} | ${a.name} | ${a.role}`).join("\n"));
359
- } catch (e) {
360
- return errorResult(e);
361
- }
362
- }
363
- );
364
-
365
- server.tool(
366
- "get_agent",
367
- "Get agent by ID or name.",
368
- { id: z.string() },
369
- async (args) => {
370
- try {
371
- const a = getAgent(args.id);
372
- if (!a) return text(`Not found: ${args.id}`);
373
- return text(`${a.id} | ${a.name} | ${a.role} | ${a.last_seen_at}`);
374
- } catch (e) {
375
- return errorResult(e);
376
- }
377
- }
378
- );
379
-
380
- // ── Project Tools ───────────────────────────────────────────────────────────
381
-
382
- server.tool(
383
- "register_project",
384
- "Register project (idempotent).",
385
- { name: z.string(), path: z.string(), description: z.string().optional() },
386
- async (args) => {
387
- try {
388
- const p = registerProject(args.name, args.path, args.description);
389
- return text(`${p.id.slice(0, 8)} | ${p.name} | ${p.path}`);
390
- } catch (e) {
391
- return errorResult(e);
392
- }
393
- }
394
- );
395
-
396
- server.tool(
397
- "list_projects",
398
- "List registered projects.",
399
- {},
400
- async () => {
401
- try {
402
- const projects = listProjects();
403
- if (projects.length === 0) return text("None.");
404
- return text(projects.map((p) => `${p.id.slice(0, 8)} | ${p.name} | ${p.path}`).join("\n"));
405
- } catch (e) {
406
- return errorResult(e);
407
- }
408
- }
409
- );
410
-
411
- // ── Heartbeat & Focus ───────────────────────────────────────────────────────
412
-
413
- server.tool(
414
- "heartbeat",
415
- "Update last_seen_at to signal agent is active. Call periodically during long tasks.",
416
- { agent_id: z.string().describe("Agent ID or name") },
417
- async (args) => {
418
- try {
419
- const agent = heartbeatAgent(args.agent_id);
420
- if (!agent) return text(`Agent not found: ${args.agent_id}`);
421
- return text(`${agent.id} | ${agent.name} | last_seen: ${agent.last_seen_at}`);
422
- } catch (e) {
423
- return errorResult(e);
424
- }
425
- }
426
- );
427
-
428
- server.tool(
429
- "set_focus",
430
- "Set active project context for this agent session.",
431
- { agent_id: z.string().describe("Agent ID or name"), project_id: z.string().nullable().optional().describe("Project ID to focus on, or null to clear") },
432
- async (args) => {
433
- try {
434
- const agent = setAgentFocus(args.agent_id, args.project_id ?? null);
435
- if (!agent) return text(`Agent not found: ${args.agent_id}`);
436
- return text(args.project_id ? `Focus set: ${args.project_id}` : "Focus cleared");
437
- } catch (e) {
438
- return errorResult(e);
439
- }
440
- }
441
- );
442
-
443
- server.tool(
444
- "send_feedback",
445
- "Send feedback about this service",
446
- {
447
- message: z.string().describe("Feedback message"),
448
- email: z.string().optional().describe("Contact email (optional)"),
449
- category: z.enum(["bug", "feature", "general"]).optional().describe("Feedback category"),
450
- },
451
- async (params: { message: string; email?: string; category?: string }) => {
452
- const adapter = getAdapter();
453
- const pkg = require("../../package.json");
454
- adapter.run(
455
- "INSERT INTO feedback (message, email, category, version) VALUES (?, ?, ?, ?)",
456
- params.message, params.email || null, params.category || "general", pkg.version
457
- );
458
- return text("Feedback saved. Thank you!");
459
- }
460
- );
461
-
462
- const transport = new StdioServerTransport();
463
- registerCloudTools(server, "recordings");
464
- await server.connect(transport);
@@ -1,122 +0,0 @@
1
- import Cocoa
2
-
3
- /// Monitors the fn/Globe key using CGEventTap.
4
- /// Based on the proven pattern from CustomWispr (open-source WisprFlow alternative).
5
- /// User must set System Settings > Keyboard > "Press fn key to: Do Nothing" for this to work.
6
- final class FnKeyMonitor: @unchecked Sendable {
7
- var onFnKeyDown: (() -> Void)?
8
- var onFnKeyUp: (() -> Void)?
9
-
10
- private var eventTap: CFMachPort?
11
- private var runLoopSource: CFRunLoopSource?
12
- private var healthCheckTimer: Timer?
13
- private var fnIsDown = false
14
-
15
- private static let fnKeyCode: UInt16 = 63
16
- private static let fnFlagMask: UInt64 = 0x800000
17
-
18
- /// Start monitoring. Returns true if successful.
19
- func start() -> Bool {
20
- guard eventTap == nil else {
21
- fputs("[FnKeyMonitor] Already running\n", stderr)
22
- return true
23
- }
24
-
25
- fputs("[FnKeyMonitor] Creating event tap...\n", stderr)
26
- let eventMask: CGEventMask = (1 << CGEventType.flagsChanged.rawValue)
27
-
28
- // passRetained so the callback reference stays alive
29
- let selfPtr = Unmanaged.passRetained(self).toOpaque()
30
-
31
- guard let tap = CGEvent.tapCreate(
32
- tap: .cgSessionEventTap,
33
- place: .headInsertEventTap,
34
- options: .defaultTap,
35
- eventsOfInterest: eventMask,
36
- callback: { (proxy, type, event, refcon) -> Unmanaged<CGEvent>? in
37
- guard let refcon = refcon else { return Unmanaged.passRetained(event) }
38
- let monitor = Unmanaged<FnKeyMonitor>.fromOpaque(refcon).takeUnretainedValue()
39
- return monitor.handleEvent(type: type, event: event)
40
- },
41
- userInfo: selfPtr
42
- ) else {
43
- Unmanaged<FnKeyMonitor>.fromOpaque(selfPtr).release()
44
- return false
45
- }
46
-
47
- self.eventTap = tap
48
-
49
- let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
50
- self.runLoopSource = source
51
- CFRunLoopAddSource(CFRunLoopGetCurrent(), source, .commonModes)
52
- CGEvent.tapEnable(tap: tap, enable: true)
53
- fputs("[FnKeyMonitor] Event tap created and enabled OK\n", stderr)
54
-
55
- // Health check: macOS silently disables taps — re-enable every 3 seconds
56
- healthCheckTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
57
- guard let self = self, let tap = self.eventTap else { return }
58
- if !CGEvent.tapIsEnabled(tap: tap) {
59
- fputs("[FnKeyMonitor] Tap was disabled, re-enabling\n", stderr)
60
- CGEvent.tapEnable(tap: tap, enable: true)
61
- }
62
- }
63
-
64
- return true
65
- }
66
-
67
- func stop() {
68
- healthCheckTimer?.invalidate()
69
- healthCheckTimer = nil
70
-
71
- if let tap = eventTap {
72
- CGEvent.tapEnable(tap: tap, enable: false)
73
- }
74
- if let source = runLoopSource {
75
- CFRunLoopRemoveSource(CFRunLoopGetCurrent(), source, .commonModes)
76
- }
77
- eventTap = nil
78
- runLoopSource = nil
79
- fnIsDown = false
80
- }
81
-
82
- private func handleEvent(type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? {
83
- // Re-enable if macOS disabled the tap
84
- if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
85
- if let tap = eventTap {
86
- CGEvent.tapEnable(tap: tap, enable: true)
87
- }
88
- return Unmanaged.passRetained(event)
89
- }
90
-
91
- let keyCode = UInt16(event.getIntegerValueField(.keyboardEventKeycode))
92
-
93
- // Only handle fn key (keyCode 63)
94
- guard keyCode == FnKeyMonitor.fnKeyCode else {
95
- return Unmanaged.passRetained(event)
96
- }
97
-
98
- let flags = event.flags.rawValue
99
- let fnPressed = (flags & FnKeyMonitor.fnFlagMask) != 0
100
-
101
- fputs("[FnKeyMonitor] flagsChanged keyCode=\(keyCode) flags=0x\(String(flags, radix: 16)) fnPressed=\(fnPressed) fnIsDown=\(fnIsDown)\n", stderr)
102
-
103
- if fnPressed && !fnIsDown {
104
- // fn just pressed
105
- fnIsDown = true
106
- fputs("[FnKeyMonitor] fn DOWN — starting recording\n", stderr)
107
- DispatchQueue.main.async { [weak self] in
108
- self?.onFnKeyDown?()
109
- }
110
- return nil // Swallow — prevents emoji picker / language switch
111
- } else if !fnPressed && fnIsDown {
112
- // fn released
113
- fnIsDown = false
114
- DispatchQueue.main.async { [weak self] in
115
- self?.onFnKeyUp?()
116
- }
117
- return nil // Swallow release too
118
- }
119
-
120
- return Unmanaged.passRetained(event)
121
- }
122
- }