@hasna/recordings 0.1.9 → 0.1.11
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/.takumi/settings.local.json +7 -0
- package/README.md +52 -0
- package/bun.lock +2 -2
- package/dist/cli/index.js +3866 -3279
- package/dist/index.js +3848 -3174
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/enhancer.d.ts +2 -2
- package/dist/lib/enhancer.d.ts.map +1 -1
- package/dist/mcp/index.js +9865 -8535
- package/package.json +3 -3
- package/src/__tests__/enhancer.test.ts +65 -0
- package/src/cli/index.ts +3 -131
- package/src/lib/config.ts +56 -16
- package/src/lib/enhancer.ts +17 -11
- package/src/mcp/index.ts +2 -0
- package/src/native/Recordings/Recordings/FnKeyMonitor.swift +58 -49
- package/src/native/Recordings/Recordings/MenuBarPopover.swift +247 -118
- package/src/native/Recordings/Recordings/ProjectStore.swift +126 -0
- package/src/native/Recordings/Recordings/RecordingEngine.swift +157 -57
- package/src/native/Recordings/Recordings/RecordingsApp.swift +12 -4
- package/src/native/Recordings/Recordings/SettingsView.swift +147 -36
- package/src/native/RecordingsHelper.swift +0 -395
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hasna/recordings",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Speech-to-text recording tool with MCP and CLI
|
|
5
|
+
"description": "Speech-to-text recording tool with MCP and CLI — records, transcribes, and optionally enhances text using AI",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
8
8
|
"bin": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"postinstall": "mkdir -p $HOME/.hasna/recordings/audio 2>/dev/null || true"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@hasna/cloud": "^0.1.
|
|
31
|
+
"@hasna/cloud": "^0.1.24",
|
|
32
32
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
33
33
|
"chalk": "^5.4.1",
|
|
34
34
|
"commander": "^13.1.0",
|
|
@@ -544,6 +544,71 @@ describe("processText", () => {
|
|
|
544
544
|
});
|
|
545
545
|
});
|
|
546
546
|
|
|
547
|
+
// ── systemPrompt support ──────────────────────────────────────────────────
|
|
548
|
+
|
|
549
|
+
describe("systemPrompt support", () => {
|
|
550
|
+
test("processText passes systemPrompt through to enhanceText", async () => {
|
|
551
|
+
let capturedMessages: Array<{ role: string; content: string }> = [];
|
|
552
|
+
mock.module("openai", () => ({
|
|
553
|
+
default: class MockOpenAI {
|
|
554
|
+
chat = {
|
|
555
|
+
completions: {
|
|
556
|
+
create: mock((opts: { messages: Array<{ role: string; content: string }> }) => {
|
|
557
|
+
capturedMessages = opts.messages;
|
|
558
|
+
return Promise.resolve({
|
|
559
|
+
choices: [{ message: { content: "Enhanced with context" } }],
|
|
560
|
+
});
|
|
561
|
+
}),
|
|
562
|
+
},
|
|
563
|
+
};
|
|
564
|
+
},
|
|
565
|
+
}));
|
|
566
|
+
|
|
567
|
+
resetEnhancementClient();
|
|
568
|
+
const { processText: process } = await import("../lib/enhancer.js");
|
|
569
|
+
resetEnhancementClient();
|
|
570
|
+
|
|
571
|
+
await process("write an email saying thanks", config, "You are working on the Acme project");
|
|
572
|
+
expect(capturedMessages[0]!.content).toContain("Additional context:");
|
|
573
|
+
expect(capturedMessages[0]!.content).toContain("Acme project");
|
|
574
|
+
|
|
575
|
+
resetEnhancementClient();
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
test("processText works without systemPrompt", async () => {
|
|
579
|
+
let capturedMessages: Array<{ role: string; content: string }> = [];
|
|
580
|
+
mock.module("openai", () => ({
|
|
581
|
+
default: class MockOpenAI {
|
|
582
|
+
chat = {
|
|
583
|
+
completions: {
|
|
584
|
+
create: mock((opts: { messages: Array<{ role: string; content: string }> }) => {
|
|
585
|
+
capturedMessages = opts.messages;
|
|
586
|
+
return Promise.resolve({
|
|
587
|
+
choices: [{ message: { content: "Enhanced without context" } }],
|
|
588
|
+
});
|
|
589
|
+
}),
|
|
590
|
+
},
|
|
591
|
+
};
|
|
592
|
+
},
|
|
593
|
+
}));
|
|
594
|
+
|
|
595
|
+
resetEnhancementClient();
|
|
596
|
+
const { processText: process } = await import("../lib/enhancer.js");
|
|
597
|
+
resetEnhancementClient();
|
|
598
|
+
|
|
599
|
+
await process("write an email saying thanks", config);
|
|
600
|
+
expect(capturedMessages[0]!.content).not.toContain("Additional context:");
|
|
601
|
+
|
|
602
|
+
resetEnhancementClient();
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
test("processText ignores systemPrompt for raw dictation", async () => {
|
|
606
|
+
const result = await processText("Just a regular note", config, "project context");
|
|
607
|
+
expect(result.mode).toBe("raw");
|
|
608
|
+
expect(result.text).toBe("Just a regular note");
|
|
609
|
+
});
|
|
610
|
+
});
|
|
611
|
+
|
|
547
612
|
// ── extractInstruction (tested via needsEnhancement) ────────────────────────
|
|
548
613
|
|
|
549
614
|
describe("extractInstruction behavior via needsEnhancement", () => {
|
package/src/cli/index.ts
CHANGED
|
@@ -148,6 +148,7 @@ program
|
|
|
148
148
|
.description("Transcribe an existing audio file")
|
|
149
149
|
.option("--no-enhance", "Skip AI enhancement")
|
|
150
150
|
.option("-t, --tags <tags>", "Comma-separated tags")
|
|
151
|
+
.option("--system-prompt <prompt>", "System prompt for enhancement context")
|
|
151
152
|
.action(async (file, opts) => {
|
|
152
153
|
const config = loadConfig();
|
|
153
154
|
ensureDataDir(config);
|
|
@@ -156,7 +157,7 @@ program
|
|
|
156
157
|
console.log(chalk.blue("Transcribing..."));
|
|
157
158
|
const transcription = await transcribeAudio(file, config);
|
|
158
159
|
|
|
159
|
-
const processed = await processText(transcription.text, config);
|
|
160
|
+
const processed = await processText(transcription.text, config, opts.systemPrompt);
|
|
160
161
|
const parentOpts = program.opts();
|
|
161
162
|
const tags = opts.tags ? opts.tags.split(",").map((t: string) => t.trim()) : [];
|
|
162
163
|
|
|
@@ -472,64 +473,6 @@ program
|
|
|
472
473
|
}
|
|
473
474
|
});
|
|
474
475
|
|
|
475
|
-
// ── start ───────────────────────────────────────────────────────────────────
|
|
476
|
-
|
|
477
|
-
program
|
|
478
|
-
.command("start")
|
|
479
|
-
.description("Launch the menu bar helper app (F5 to toggle recording)")
|
|
480
|
-
.option("--login", "Also add to Login Items so it starts automatically")
|
|
481
|
-
.action(async (opts) => {
|
|
482
|
-
const { execSync } = require("node:child_process") as typeof import("node:child_process");
|
|
483
|
-
const { join: pathJoin } = require("node:path") as typeof import("node:path");
|
|
484
|
-
const { homedir: getHome } = require("node:os") as typeof import("node:os");
|
|
485
|
-
const { existsSync: fileExists } = require("node:fs") as typeof import("node:fs");
|
|
486
|
-
const home = getHome();
|
|
487
|
-
|
|
488
|
-
const appPath = pathJoin(home, ".hasna", "recordings", "RecordingsHelper.app");
|
|
489
|
-
const oldAppPath = pathJoin(home, ".recordings", "RecordingsHelper.app");
|
|
490
|
-
|
|
491
|
-
if (!fileExists(appPath) && !fileExists(oldAppPath)) {
|
|
492
|
-
console.error(chalk.red("RecordingsHelper.app not found. Run: recordings shortcut --install"));
|
|
493
|
-
process.exit(1);
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
const resolvedAppPath = fileExists(appPath) ? appPath : oldAppPath;
|
|
497
|
-
|
|
498
|
-
// Kill existing instance
|
|
499
|
-
try { execSync("pkill -f RecordingsHelper", { stdio: "pipe" }); } catch { /* not running */ }
|
|
500
|
-
|
|
501
|
-
// Launch
|
|
502
|
-
execSync(`open "${resolvedAppPath}"`, { stdio: "pipe" });
|
|
503
|
-
console.log(chalk.green("Recordings helper launched — press F5 to record"));
|
|
504
|
-
|
|
505
|
-
if (opts.login) {
|
|
506
|
-
try {
|
|
507
|
-
execSync(
|
|
508
|
-
`osascript -e 'tell application "System Events" to make login item at end with properties {path:"${resolvedAppPath}", hidden:true}'`,
|
|
509
|
-
{ stdio: "pipe" }
|
|
510
|
-
);
|
|
511
|
-
console.log(chalk.green("Added to Login Items — will start on boot"));
|
|
512
|
-
} catch {
|
|
513
|
-
console.log(chalk.yellow("Could not add to Login Items — add manually in System Settings > General > Login Items"));
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
});
|
|
517
|
-
|
|
518
|
-
// ── stop ────────────────────────────────────────────────────────────────────
|
|
519
|
-
|
|
520
|
-
program
|
|
521
|
-
.command("stop")
|
|
522
|
-
.description("Stop the menu bar helper app")
|
|
523
|
-
.action(() => {
|
|
524
|
-
const { execSync } = require("node:child_process") as typeof import("node:child_process");
|
|
525
|
-
try {
|
|
526
|
-
execSync("pkill -f RecordingsHelper", { stdio: "pipe" });
|
|
527
|
-
console.log(chalk.green("Recordings helper stopped"));
|
|
528
|
-
} catch {
|
|
529
|
-
console.log(chalk.dim("Not running"));
|
|
530
|
-
}
|
|
531
|
-
});
|
|
532
|
-
|
|
533
476
|
// ── listen ───────────────────────────────────────────────────────────────────
|
|
534
477
|
|
|
535
478
|
program
|
|
@@ -680,13 +623,12 @@ program
|
|
|
680
623
|
.command("shortcut")
|
|
681
624
|
.description("Set up a global keyboard shortcut for recording (macOS)")
|
|
682
625
|
.option("--raycast", "Generate Raycast script command")
|
|
683
|
-
.option("--install", "Set up F5 global shortcut via macOS Services (no extra installs)")
|
|
684
626
|
.option("--karabiner", "Set up Fn key via Karabiner-Elements")
|
|
685
627
|
.option("--skhd", "Generate skhd hotkey config")
|
|
686
628
|
.option("--hammerspoon", "Generate Hammerspoon config")
|
|
687
629
|
.option("--script", "Just output the shell script path")
|
|
688
630
|
.action((opts) => {
|
|
689
|
-
const { writeFileSync, mkdirSync, chmodSync
|
|
631
|
+
const { writeFileSync, mkdirSync, chmodSync } = require("node:fs") as typeof import("node:fs");
|
|
690
632
|
const { join: pathJoin } = require("node:path") as typeof import("node:path");
|
|
691
633
|
const { homedir: getHome } = require("node:os") as typeof import("node:os");
|
|
692
634
|
const home = getHome();
|
|
@@ -746,72 +688,6 @@ fi
|
|
|
746
688
|
writeFileSync(scriptPath, script, "utf-8");
|
|
747
689
|
chmodSync(scriptPath, 0o755);
|
|
748
690
|
|
|
749
|
-
if (opts.install) {
|
|
750
|
-
// Install the native menu bar app — no config needed, just works with F5
|
|
751
|
-
const { execSync: exec } = require("node:child_process") as typeof import("node:child_process");
|
|
752
|
-
|
|
753
|
-
const appPath = pathJoin(home, ".hasna", "recordings", "RecordingsHelper.app");
|
|
754
|
-
const srcSwift = pathJoin(__dirname, "..", "native", "RecordingsHelper.swift");
|
|
755
|
-
const distApp = pathJoin(__dirname, "..", "RecordingsHelper.app");
|
|
756
|
-
|
|
757
|
-
// Copy pre-built app if available, otherwise compile
|
|
758
|
-
if (fileExists(pathJoin(distApp, "Contents", "MacOS", "RecordingsHelper"))) {
|
|
759
|
-
exec(`rm -rf "${appPath}" && cp -R "${distApp}" "${appPath}"`, { stdio: "pipe", shell: "/bin/bash" });
|
|
760
|
-
} else if (fileExists(srcSwift)) {
|
|
761
|
-
// Compile from source
|
|
762
|
-
console.log(chalk.blue("Compiling native helper app..."));
|
|
763
|
-
const appDir = pathJoin(home, ".hasna", "recordings", "RecordingsHelper.app", "Contents", "MacOS");
|
|
764
|
-
mkdirSync(appDir, { recursive: true });
|
|
765
|
-
|
|
766
|
-
const plistDir = pathJoin(home, ".hasna", "recordings", "RecordingsHelper.app", "Contents");
|
|
767
|
-
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
768
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
769
|
-
<plist version="1.0"><dict>
|
|
770
|
-
<key>CFBundleExecutable</key><string>RecordingsHelper</string>
|
|
771
|
-
<key>CFBundleIdentifier</key><string>com.hasna.recordings-helper</string>
|
|
772
|
-
<key>CFBundleName</key><string>Recordings</string>
|
|
773
|
-
<key>LSUIElement</key><true/>
|
|
774
|
-
<key>NSMicrophoneUsageDescription</key><string>Recordings needs microphone access for speech transcription.</string>
|
|
775
|
-
</dict></plist>`;
|
|
776
|
-
writeFileSync(pathJoin(plistDir, "Info.plist"), plist, "utf-8");
|
|
777
|
-
|
|
778
|
-
try {
|
|
779
|
-
exec(
|
|
780
|
-
`DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcrun swiftc -O -o "${appDir}/RecordingsHelper" "${srcSwift}" -framework Cocoa -framework Carbon`,
|
|
781
|
-
{ stdio: "pipe" }
|
|
782
|
-
);
|
|
783
|
-
} catch {
|
|
784
|
-
// Fallback to default toolchain
|
|
785
|
-
exec(
|
|
786
|
-
`swiftc -O -o "${appDir}/RecordingsHelper" "${srcSwift}" -framework Cocoa -framework Carbon`,
|
|
787
|
-
{ stdio: "pipe" }
|
|
788
|
-
);
|
|
789
|
-
}
|
|
790
|
-
} else {
|
|
791
|
-
console.error(chalk.red("Cannot find RecordingsHelper. Run from the project directory or rebuild."));
|
|
792
|
-
process.exit(1);
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
// Kill existing instance and launch
|
|
796
|
-
try { exec("pkill -f RecordingsHelper", { stdio: "pipe" }); } catch { /* not running */ }
|
|
797
|
-
exec(`open "${appPath}"`, { stdio: "pipe" });
|
|
798
|
-
|
|
799
|
-
// Add to Login Items
|
|
800
|
-
try {
|
|
801
|
-
exec(
|
|
802
|
-
`osascript -e 'tell application "System Events" to make login item at end with properties {path:"${appPath}", hidden:true}'`,
|
|
803
|
-
{ stdio: "pipe" }
|
|
804
|
-
);
|
|
805
|
-
} catch { /* already exists or no permission */ }
|
|
806
|
-
|
|
807
|
-
console.log(chalk.green("\nRecordings helper installed and running!\n"));
|
|
808
|
-
console.log(` ${chalk.yellow("F5")} Start/stop recording`);
|
|
809
|
-
console.log(` ${chalk.dim("🎙")} Menu bar icon (click for options)`);
|
|
810
|
-
console.log(` ${chalk.dim("Auto")} Starts on login\n`);
|
|
811
|
-
console.log(chalk.dim(" Press F5 → speak → F5 → text is pasted where your cursor is."));
|
|
812
|
-
return;
|
|
813
|
-
}
|
|
814
|
-
|
|
815
691
|
if (opts.karabiner) {
|
|
816
692
|
const karabinerDir = pathJoin(home, ".config", "karabiner", "assets", "complex_modifications");
|
|
817
693
|
mkdirSync(karabinerDir, { recursive: true });
|
|
@@ -900,9 +776,6 @@ ${scriptPath}
|
|
|
900
776
|
console.log(chalk.cyan(` ${scriptPath}\n`));
|
|
901
777
|
console.log("Bind it to a hotkey using any of these:\n");
|
|
902
778
|
|
|
903
|
-
console.log(chalk.bold(" macOS built-in") + chalk.dim(" (no extra installs — recommended)"));
|
|
904
|
-
console.log(` recordings shortcut --install\n`);
|
|
905
|
-
|
|
906
779
|
console.log(chalk.bold(" Karabiner-Elements") + chalk.dim(" (for Fn key specifically)"));
|
|
907
780
|
console.log(` brew install --cask karabiner-elements`);
|
|
908
781
|
console.log(` recordings shortcut --karabiner\n`);
|
|
@@ -1082,7 +955,6 @@ program
|
|
|
1082
955
|
.command("remove <id>")
|
|
1083
956
|
.alias("rm")
|
|
1084
957
|
.alias("uninstall")
|
|
1085
|
-
.alias("delete")
|
|
1086
958
|
.description("Delete a recording by ID")
|
|
1087
959
|
.action((id: string) => {
|
|
1088
960
|
const deleted = deleteRecording(id);
|
package/src/lib/config.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync, mkdirSync, cpSync } from "fs";
|
|
1
|
+
import { existsSync, readFileSync, mkdirSync, cpSync, readdirSync, statSync } from "fs";
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
import { homedir } from "os";
|
|
4
4
|
import type { RecordingsConfig } from "../types/index.js";
|
|
@@ -42,7 +42,7 @@ export function loadConfig(configPath?: string): RecordingsConfig {
|
|
|
42
42
|
try {
|
|
43
43
|
const raw = readFileSync(filePath, "utf-8");
|
|
44
44
|
const fileConfig = JSON.parse(raw) as Partial<RecordingsConfig>;
|
|
45
|
-
Object.assign(config, fileConfig);
|
|
45
|
+
Object.assign(config, expandEnvBackedConfig(fileConfig));
|
|
46
46
|
} catch {
|
|
47
47
|
// Ignore invalid config files
|
|
48
48
|
}
|
|
@@ -102,6 +102,19 @@ export function loadConfig(configPath?: string): RecordingsConfig {
|
|
|
102
102
|
return config;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
function expandEnvBackedConfig(config: Partial<RecordingsConfig>): Partial<RecordingsConfig> {
|
|
106
|
+
const expanded = { ...config };
|
|
107
|
+
|
|
108
|
+
for (const key of ["openai_api_key", "enhancement_api_key"] as const) {
|
|
109
|
+
const value = expanded[key];
|
|
110
|
+
if (typeof value === "string" && value.startsWith("$") && value.length > 1) {
|
|
111
|
+
expanded[key] = process.env[value.slice(1)] || value;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return expanded;
|
|
116
|
+
}
|
|
117
|
+
|
|
105
118
|
function findConfigFile(): string | null {
|
|
106
119
|
// Walk up from cwd looking for .recordings/config.json
|
|
107
120
|
let dir = process.cwd();
|
|
@@ -150,24 +163,51 @@ function loadSecretKey(keyName: string): string {
|
|
|
150
163
|
const secretsPath = join(homedir(), ".secrets");
|
|
151
164
|
if (!existsSync(secretsPath)) return "";
|
|
152
165
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
166
|
+
for (const candidate of listSecretFiles(secretsPath)) {
|
|
167
|
+
try {
|
|
168
|
+
const content = readFileSync(candidate, "utf-8");
|
|
169
|
+
const match = content.match(
|
|
170
|
+
new RegExp(`export\\s+${keyName}\\s*=\\s*"([^"]+)"`)
|
|
171
|
+
);
|
|
172
|
+
if (match) return match[1]!;
|
|
173
|
+
|
|
174
|
+
const match2 = content.match(
|
|
175
|
+
new RegExp(`export\\s+${keyName}\\s*=\\s*'([^']+)'`)
|
|
176
|
+
);
|
|
177
|
+
if (match2) return match2[1]!;
|
|
178
|
+
|
|
179
|
+
const match3 = content.match(new RegExp(`${keyName}\\s*=\\s*(.+)`));
|
|
180
|
+
if (match3) return match3[1]!.trim().replace(/^["']|["']$/g, "");
|
|
181
|
+
} catch {
|
|
182
|
+
// Ignore unreadable secret files
|
|
183
|
+
}
|
|
184
|
+
}
|
|
159
185
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
);
|
|
163
|
-
if (match2) return match2[1]!;
|
|
186
|
+
return "";
|
|
187
|
+
}
|
|
164
188
|
|
|
165
|
-
|
|
166
|
-
|
|
189
|
+
function listSecretFiles(path: string): string[] {
|
|
190
|
+
try {
|
|
191
|
+
const stats = statSync(path);
|
|
192
|
+
if (stats.isFile()) return [path];
|
|
193
|
+
if (!stats.isDirectory()) return [];
|
|
194
|
+
|
|
195
|
+
return readdirSync(path)
|
|
196
|
+
.sort()
|
|
197
|
+
.flatMap((entry) => {
|
|
198
|
+
const child = join(path, entry);
|
|
199
|
+
try {
|
|
200
|
+
const childStats = statSync(child);
|
|
201
|
+
if (childStats.isDirectory()) return listSecretFiles(child);
|
|
202
|
+
if (childStats.isFile() && child.endsWith(".env")) return [child];
|
|
203
|
+
} catch {
|
|
204
|
+
// Ignore entries that disappear or are unreadable
|
|
205
|
+
}
|
|
206
|
+
return [];
|
|
207
|
+
});
|
|
167
208
|
} catch {
|
|
168
|
-
|
|
209
|
+
return [];
|
|
169
210
|
}
|
|
170
|
-
return "";
|
|
171
211
|
}
|
|
172
212
|
|
|
173
213
|
export function ensureDataDir(config: RecordingsConfig): void {
|
package/src/lib/enhancer.ts
CHANGED
|
@@ -92,17 +92,12 @@ function extractInstruction(text: string, trigger: string): string {
|
|
|
92
92
|
export async function enhanceText(
|
|
93
93
|
rawText: string,
|
|
94
94
|
instruction: string,
|
|
95
|
-
config: RecordingsConfig
|
|
95
|
+
config: RecordingsConfig,
|
|
96
|
+
systemPrompt?: string
|
|
96
97
|
): Promise<EnhancementResult> {
|
|
97
98
|
const client = getEnhancementClient(config);
|
|
98
99
|
|
|
99
|
-
|
|
100
|
-
const response = await client.chat.completions.create({
|
|
101
|
-
model: config.enhancement_model,
|
|
102
|
-
messages: [
|
|
103
|
-
{
|
|
104
|
-
role: "system",
|
|
105
|
-
content: `You are a writing assistant. The user has dictated speech that needs to be transformed into polished output.
|
|
100
|
+
const basePrompt = `You are a writing assistant. The user has dictated speech that needs to be transformed into polished output.
|
|
106
101
|
|
|
107
102
|
Rules:
|
|
108
103
|
- Output ONLY the enhanced/rewritten text — no explanations, no preamble
|
|
@@ -110,7 +105,17 @@ Rules:
|
|
|
110
105
|
- Fix grammar, structure, and clarity
|
|
111
106
|
- If the user is giving instructions (e.g., "write an email saying..."), produce the actual output (the email), not a description of it
|
|
112
107
|
- If the user says "say it better" or similar, rewrite their preceding text to be clearer and more professional
|
|
113
|
-
- Match the appropriate tone (formal for business, casual for personal)
|
|
108
|
+
- Match the appropriate tone (formal for business, casual for personal)`;
|
|
109
|
+
|
|
110
|
+
const fullPrompt = systemPrompt ? `${basePrompt}\n\nAdditional context:\n${systemPrompt}` : basePrompt;
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const response = await client.chat.completions.create({
|
|
114
|
+
model: config.enhancement_model,
|
|
115
|
+
messages: [
|
|
116
|
+
{
|
|
117
|
+
role: "system",
|
|
118
|
+
content: fullPrompt,
|
|
114
119
|
},
|
|
115
120
|
{
|
|
116
121
|
role: "user",
|
|
@@ -141,7 +146,8 @@ Rules:
|
|
|
141
146
|
*/
|
|
142
147
|
export async function processText(
|
|
143
148
|
rawText: string,
|
|
144
|
-
config: RecordingsConfig
|
|
149
|
+
config: RecordingsConfig,
|
|
150
|
+
systemPrompt?: string
|
|
145
151
|
): Promise<{
|
|
146
152
|
text: string;
|
|
147
153
|
mode: "raw" | "enhanced";
|
|
@@ -157,7 +163,7 @@ export async function processText(
|
|
|
157
163
|
return { text: rawText, mode: "raw", enhancement_model: null };
|
|
158
164
|
}
|
|
159
165
|
|
|
160
|
-
const result = await enhanceText(rawText, detection.instruction, config);
|
|
166
|
+
const result = await enhanceText(rawText, detection.instruction, config, systemPrompt);
|
|
161
167
|
|
|
162
168
|
return {
|
|
163
169
|
text: result.enhanced,
|
package/src/mcp/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { registerCloudTools } from "@hasna/cloud";
|
|
4
5
|
import { z } from "zod";
|
|
5
6
|
import { loadConfig, ensureDataDir } from "../lib/config.js";
|
|
6
7
|
import { getDatabase, getAdapter } from "../db/database.js";
|
|
@@ -459,4 +460,5 @@ server.tool(
|
|
|
459
460
|
);
|
|
460
461
|
|
|
461
462
|
const transport = new StdioServerTransport();
|
|
463
|
+
registerCloudTools(server, "recordings");
|
|
462
464
|
await server.connect(transport);
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import Cocoa
|
|
2
2
|
|
|
3
3
|
/// Monitors the fn/Globe key using CGEventTap.
|
|
4
|
-
///
|
|
5
|
-
///
|
|
4
|
+
/// Fn is exposed as a modifier flag, so we watch flagsChanged events and
|
|
5
|
+
/// check CGEventFlags.maskSecondaryFn rather than relying on a raw bit mask.
|
|
6
6
|
final class FnKeyMonitor: @unchecked Sendable {
|
|
7
7
|
var onFnKeyDown: (() -> Void)?
|
|
8
8
|
var onFnKeyUp: (() -> Void)?
|
|
@@ -13,7 +13,6 @@ final class FnKeyMonitor: @unchecked Sendable {
|
|
|
13
13
|
private var fnIsDown = false
|
|
14
14
|
|
|
15
15
|
private static let fnKeyCode: UInt16 = 63
|
|
16
|
-
private static let fnFlagMask: UInt64 = 0x800000
|
|
17
16
|
|
|
18
17
|
/// Start monitoring. Returns true if successful.
|
|
19
18
|
func start() -> Bool {
|
|
@@ -22,46 +21,54 @@ final class FnKeyMonitor: @unchecked Sendable {
|
|
|
22
21
|
return true
|
|
23
22
|
}
|
|
24
23
|
|
|
25
|
-
|
|
26
|
-
let
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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)
|
|
24
|
+
let eventMask: CGEventMask = 1 << CGEventType.flagsChanged.rawValue
|
|
25
|
+
let selfPtr = Unmanaged.passUnretained(self).toOpaque()
|
|
26
|
+
let candidates: [(CGEventTapLocation, CGEventTapPlacement, String)] = [
|
|
27
|
+
(.cgAnnotatedSessionEventTap, .tailAppendEventTap, "annotated-session/tail"),
|
|
28
|
+
(.cgSessionEventTap, .tailAppendEventTap, "session/tail"),
|
|
29
|
+
(.cghidEventTap, .headInsertEventTap, "hid/head"),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
for (tapLocation, tapPlacement, label) in candidates {
|
|
33
|
+
fputs("[FnKeyMonitor] Trying event tap: \(label)\n", stderr)
|
|
34
|
+
|
|
35
|
+
guard let tap = CGEvent.tapCreate(
|
|
36
|
+
tap: tapLocation,
|
|
37
|
+
place: tapPlacement,
|
|
38
|
+
options: .defaultTap,
|
|
39
|
+
eventsOfInterest: eventMask,
|
|
40
|
+
callback: { _, type, event, refcon -> Unmanaged<CGEvent>? in
|
|
41
|
+
guard let refcon else { return Unmanaged.passRetained(event) }
|
|
42
|
+
let monitor = Unmanaged<FnKeyMonitor>.fromOpaque(refcon).takeUnretainedValue()
|
|
43
|
+
return monitor.handleEvent(type: type, event: event)
|
|
44
|
+
},
|
|
45
|
+
userInfo: selfPtr
|
|
46
|
+
) else {
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
54
49
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
50
|
+
eventTap = tap
|
|
51
|
+
runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
|
|
52
|
+
if let runLoopSource {
|
|
53
|
+
CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
|
|
54
|
+
}
|
|
55
|
+
CGEvent.tapEnable(tap: tap, enable: true)
|
|
56
|
+
fputs("[FnKeyMonitor] Event tap ready: \(label)\n", stderr)
|
|
57
|
+
|
|
58
|
+
// Health check: macOS can silently disable taps — re-enable every 3 seconds.
|
|
59
|
+
healthCheckTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
|
|
60
|
+
guard let self, let tap = self.eventTap else { return }
|
|
61
|
+
if !CGEvent.tapIsEnabled(tap: tap) {
|
|
62
|
+
fputs("[FnKeyMonitor] Tap was disabled, re-enabling\n", stderr)
|
|
63
|
+
CGEvent.tapEnable(tap: tap, enable: true)
|
|
64
|
+
}
|
|
61
65
|
}
|
|
66
|
+
|
|
67
|
+
return true
|
|
62
68
|
}
|
|
63
69
|
|
|
64
|
-
|
|
70
|
+
fputs("[FnKeyMonitor] Failed to create any event tap\n", stderr)
|
|
71
|
+
return false
|
|
65
72
|
}
|
|
66
73
|
|
|
67
74
|
func stop() {
|
|
@@ -80,7 +87,6 @@ final class FnKeyMonitor: @unchecked Sendable {
|
|
|
80
87
|
}
|
|
81
88
|
|
|
82
89
|
private func handleEvent(type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? {
|
|
83
|
-
// Re-enable if macOS disabled the tap
|
|
84
90
|
if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
|
|
85
91
|
if let tap = eventTap {
|
|
86
92
|
CGEvent.tapEnable(tap: tap, enable: true)
|
|
@@ -88,33 +94,36 @@ final class FnKeyMonitor: @unchecked Sendable {
|
|
|
88
94
|
return Unmanaged.passRetained(event)
|
|
89
95
|
}
|
|
90
96
|
|
|
97
|
+
guard type == .flagsChanged else {
|
|
98
|
+
return Unmanaged.passRetained(event)
|
|
99
|
+
}
|
|
100
|
+
|
|
91
101
|
let keyCode = UInt16(event.getIntegerValueField(.keyboardEventKeycode))
|
|
102
|
+
let fnPressed = event.flags.contains(.maskSecondaryFn)
|
|
103
|
+
let isFnTransition = keyCode == FnKeyMonitor.fnKeyCode || fnPressed || fnIsDown
|
|
92
104
|
|
|
93
|
-
|
|
94
|
-
guard keyCode == FnKeyMonitor.fnKeyCode else {
|
|
105
|
+
guard isFnTransition else {
|
|
95
106
|
return Unmanaged.passRetained(event)
|
|
96
107
|
}
|
|
97
108
|
|
|
98
109
|
let flags = event.flags.rawValue
|
|
99
|
-
let fnPressed = (flags & FnKeyMonitor.fnFlagMask) != 0
|
|
100
|
-
|
|
101
110
|
fputs("[FnKeyMonitor] flagsChanged keyCode=\(keyCode) flags=0x\(String(flags, radix: 16)) fnPressed=\(fnPressed) fnIsDown=\(fnIsDown)\n", stderr)
|
|
102
111
|
|
|
103
112
|
if fnPressed && !fnIsDown {
|
|
104
|
-
// fn just pressed
|
|
105
113
|
fnIsDown = true
|
|
106
114
|
fputs("[FnKeyMonitor] fn DOWN — starting recording\n", stderr)
|
|
107
115
|
DispatchQueue.main.async { [weak self] in
|
|
108
116
|
self?.onFnKeyDown?()
|
|
109
117
|
}
|
|
110
|
-
return nil
|
|
111
|
-
}
|
|
112
|
-
|
|
118
|
+
return nil
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if !fnPressed && fnIsDown {
|
|
113
122
|
fnIsDown = false
|
|
114
123
|
DispatchQueue.main.async { [weak self] in
|
|
115
124
|
self?.onFnKeyUp?()
|
|
116
125
|
}
|
|
117
|
-
return nil
|
|
126
|
+
return nil
|
|
118
127
|
}
|
|
119
128
|
|
|
120
129
|
return Unmanaged.passRetained(event)
|