Compare commits
3 Commits
70e784a1cc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 772d58b55e | |||
| c2aea05000 | |||
| eb0619dea2 |
@@ -30,6 +30,7 @@ public class Program
|
||||
rootCommand.AddCommand(SetupCommand.Create());
|
||||
rootCommand.AddCommand(LatencyTestCommand.Create());
|
||||
rootCommand.AddCommand(ShowCommand.Create());
|
||||
rootCommand.AddCommand(ProfilesCommand.Create());
|
||||
|
||||
return rootCommand;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
using Hush.Config;
|
||||
using Hush.Daemon;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace Hush.Cli.Commands;
|
||||
|
||||
public static class ProfilesCommand
|
||||
{
|
||||
private static readonly string ProfileTemplate =
|
||||
"# Hush profile — only fields listed here override the base config.\n" +
|
||||
"# All fields are optional. Delete any line you don't want to override.\n" +
|
||||
"#\n" +
|
||||
"# Available fields:\n" +
|
||||
"# whisper_provider = \"groq\" # or \"fireworks\"\n" +
|
||||
"# llm_provider = \"groq\" # or \"fireworks\"\n" +
|
||||
"# llm_model = \"openai/gpt-oss-20b\"\n" +
|
||||
"# whisper_model = \"whisper-large-v3-turbo\"\n" +
|
||||
"# whisper_language = \"en\" # ISO-639-1, empty = auto-detect\n" +
|
||||
"# system_prompt = \"\"\"\n" +
|
||||
"# Your custom instruction for the LLM goes here.\n" +
|
||||
"# Output only the final result with no explanation.\n" +
|
||||
"# \"\"\"\n" +
|
||||
"\n" +
|
||||
"system_prompt = \"\"\"\n" +
|
||||
"You are a transcription post-processor. Clean up the raw speech-to-text output\n" +
|
||||
"and return polished, ready-to-type text. Fix grammar, punctuation, and remove\n" +
|
||||
"filler words. Output only the corrected text with no explanation.\n" +
|
||||
"\"\"\"\n";
|
||||
|
||||
public static Command Create()
|
||||
{
|
||||
var profiles = new Command("profiles", "Manage configuration profiles");
|
||||
|
||||
profiles.AddCommand(CreateListCommand());
|
||||
profiles.AddCommand(CreateGetCommand());
|
||||
profiles.AddCommand(CreateNewCommand());
|
||||
profiles.AddCommand(CreateEditCommand());
|
||||
|
||||
return profiles;
|
||||
}
|
||||
|
||||
// ── list ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static Command CreateListCommand()
|
||||
{
|
||||
var cmd = new Command("list", "List all available profiles");
|
||||
cmd.SetHandler(() =>
|
||||
{
|
||||
var manager = new ConfigManager();
|
||||
var profiles = manager.ListProfiles().ToList();
|
||||
|
||||
if (profiles.Count == 0)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[grey]No profiles found. Use 'hush profiles new <name>' to create one.[/]");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var name in profiles)
|
||||
AnsiConsole.WriteLine(name);
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
// ── get ──────────────────────────────────────────────────────────────────
|
||||
|
||||
private static Command CreateGetCommand()
|
||||
{
|
||||
var nameArg = new Argument<string>("name", "Profile name");
|
||||
var cmd = new Command("get", "Print the contents of a profile");
|
||||
cmd.AddArgument(nameArg);
|
||||
|
||||
cmd.SetHandler((context) =>
|
||||
{
|
||||
var name = context.ParseResult.GetValueForArgument(nameArg);
|
||||
var manager = new ConfigManager();
|
||||
var path = manager.GetProfilePath(name);
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Profile '{name}' not found.[/]");
|
||||
context.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
Console.Write(File.ReadAllText(path));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
// ── new ──────────────────────────────────────────────────────────────────
|
||||
|
||||
private static Command CreateNewCommand()
|
||||
{
|
||||
var nameArg = new Argument<string>("name", "Profile name");
|
||||
var cmd = new Command("new", "Create a new profile");
|
||||
cmd.AddArgument(nameArg);
|
||||
|
||||
cmd.SetHandler(async (context) =>
|
||||
{
|
||||
var name = context.ParseResult.GetValueForArgument(nameArg);
|
||||
var manager = new ConfigManager();
|
||||
var path = manager.GetProfilePath(name);
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Profile '{name}' already exists. Use 'hush profiles edit {name}' to edit it.[/]");
|
||||
context.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Ask creation mode
|
||||
var mode = AnsiConsole.Prompt(
|
||||
new SelectionPrompt<string>()
|
||||
.Title("How do you want to create this profile?")
|
||||
.AddChoices("Generate with AI (describe what you want)", "Create manually (edit a template)"));
|
||||
|
||||
string initialContent;
|
||||
|
||||
if (mode.StartsWith("Generate"))
|
||||
{
|
||||
var description = AnsiConsole.Ask<string>("Describe what you want this profile to do:");
|
||||
|
||||
var generatedPrompt = await AnsiConsole.Status()
|
||||
.Spinner(Spinner.Known.Dots)
|
||||
.StartAsync("Generating system prompt...", async _ =>
|
||||
await GenerateSystemPromptAsync(description));
|
||||
|
||||
if (generatedPrompt == null)
|
||||
{
|
||||
context.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
initialContent =
|
||||
$"# Hush profile — AI-generated for: {description}\n" +
|
||||
"# Review and adjust as needed, then save and close your editor.\n" +
|
||||
"\n" +
|
||||
"system_prompt = \"\"\"\n" +
|
||||
$"{generatedPrompt}\n" +
|
||||
"\"\"\"\n";
|
||||
}
|
||||
else
|
||||
{
|
||||
initialContent = ProfileTemplate;
|
||||
}
|
||||
|
||||
manager.EnsureProfilesDirExists();
|
||||
File.WriteAllText(path, initialContent);
|
||||
|
||||
OpenInEditor(path);
|
||||
AnsiConsole.MarkupLine($"[green]Profile '{name}' saved.[/]");
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
// ── edit ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static Command CreateEditCommand()
|
||||
{
|
||||
var nameArg = new Argument<string>("name", "Profile name");
|
||||
var cmd = new Command("edit", "Edit an existing profile in $EDITOR");
|
||||
cmd.AddArgument(nameArg);
|
||||
|
||||
cmd.SetHandler((context) =>
|
||||
{
|
||||
var name = context.ParseResult.GetValueForArgument(nameArg);
|
||||
var manager = new ConfigManager();
|
||||
var path = manager.GetProfilePath(name);
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Profile '{name}' not found. Use 'hush profiles new {name}' to create it.[/]");
|
||||
context.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
OpenInEditor(path);
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<string?> GenerateSystemPromptAsync(string description)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var client = new SocketClient();
|
||||
await client.ConnectAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
var request = new GenerateProfileRequest(description);
|
||||
await client.SendRequestAsync(
|
||||
DaemonProtocol.GENERATE_PROFILE,
|
||||
request,
|
||||
DaemonJsonContext.Default.GenerateProfileRequest);
|
||||
|
||||
var json = await client.ReceiveRawJsonAsync(TimeSpan.FromSeconds(30));
|
||||
if (json == null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]No response from daemon.[/]");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check for error response first
|
||||
var error = JsonSerializer.Deserialize(json, DaemonJsonContext.Default.ErrorResponse);
|
||||
if (error?.Error != null)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Daemon error: {error.Error}[/]");
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = JsonSerializer.Deserialize(json, DaemonJsonContext.Default.GenerateProfileResponse);
|
||||
return result?.SystemPrompt;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error: {ex.Message}[/]");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void OpenInEditor(string path)
|
||||
{
|
||||
var editor = Environment.GetEnvironmentVariable("EDITOR");
|
||||
if (string.IsNullOrEmpty(editor))
|
||||
editor = "nano";
|
||||
|
||||
try
|
||||
{
|
||||
var process = new System.Diagnostics.Process
|
||||
{
|
||||
StartInfo = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = editor,
|
||||
Arguments = $"\"{path}\"",
|
||||
UseShellExecute = false
|
||||
}
|
||||
};
|
||||
process.Start();
|
||||
process.WaitForExit();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Could not open editor '{editor}': {ex.Message}[/]");
|
||||
AnsiConsole.MarkupLine($"Profile saved at: {path}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.CommandLine;
|
||||
using Hush.Config;
|
||||
using Hush.Daemon;
|
||||
using Spectre.Console;
|
||||
|
||||
@@ -9,13 +10,28 @@ public static class StopCommand
|
||||
public static Command Create()
|
||||
{
|
||||
var command = new Command("stop", "Stop recording and process");
|
||||
|
||||
var profileOption = new Option<string?>(["--profile", "-p"], "Profile name to apply when processing");
|
||||
command.AddOption(profileOption);
|
||||
|
||||
command.SetHandler(async (context) =>
|
||||
{
|
||||
var profileName = context.ParseResult.GetValueForOption(profileOption);
|
||||
try
|
||||
{
|
||||
await using var client = new SocketClient();
|
||||
await client.ConnectAsync(TimeSpan.FromSeconds(2));
|
||||
await client.SendCommandAsync(DaemonProtocol.STOP);
|
||||
|
||||
if (!string.IsNullOrEmpty(profileName))
|
||||
{
|
||||
var config = new ConfigManager().LoadWithProfile(profileName);
|
||||
await client.SendCommandWithConfigAsync(DaemonProtocol.STOP, config);
|
||||
}
|
||||
else
|
||||
{
|
||||
await client.SendCommandAsync(DaemonProtocol.STOP);
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine("[green]Stop command sent[/]");
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.CommandLine;
|
||||
using Hush.Config;
|
||||
using Hush.Daemon;
|
||||
using Spectre.Console;
|
||||
|
||||
@@ -9,13 +10,28 @@ public static class ToggleCommand
|
||||
public static Command Create()
|
||||
{
|
||||
var command = new Command("toggle", "Toggle recording (start if idle, stop if recording)");
|
||||
|
||||
var profileOption = new Option<string?>(["--profile", "-p"], "Profile name to apply when processing stops");
|
||||
command.AddOption(profileOption);
|
||||
|
||||
command.SetHandler(async (context) =>
|
||||
{
|
||||
var profileName = context.ParseResult.GetValueForOption(profileOption);
|
||||
try
|
||||
{
|
||||
await using var client = new SocketClient();
|
||||
await client.ConnectAsync(TimeSpan.FromSeconds(2));
|
||||
await client.SendCommandAsync(DaemonProtocol.TOGGLE);
|
||||
|
||||
if (!string.IsNullOrEmpty(profileName))
|
||||
{
|
||||
var config = new ConfigManager().LoadWithProfile(profileName);
|
||||
await client.SendCommandWithConfigAsync(DaemonProtocol.TOGGLE, config);
|
||||
}
|
||||
else
|
||||
{
|
||||
await client.SendCommandAsync(DaemonProtocol.TOGGLE);
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine("[green]Toggle command sent[/]");
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Hush.Config;
|
||||
using Hush.Daemon;
|
||||
|
||||
namespace Hush.Cli;
|
||||
@@ -15,7 +17,7 @@ public class SocketClient : IAsyncDisposable
|
||||
var runtimeDir = Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR");
|
||||
var baseDir = string.IsNullOrEmpty(runtimeDir) ? Path.GetTempPath() : runtimeDir;
|
||||
var socketPath = Path.Combine(baseDir, "hush.sock");
|
||||
|
||||
|
||||
_endPoint = new UnixDomainSocketEndPoint(socketPath);
|
||||
_socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
|
||||
}
|
||||
@@ -26,9 +28,58 @@ public class SocketClient : IAsyncDisposable
|
||||
await _socket.ConnectAsync(_endPoint, cts.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a command with no config payload.
|
||||
/// Action commands (START/STOP/TOGGLE/ABORT) always include a 4-byte zero length prefix
|
||||
/// so the daemon can read the same framing unconditionally.
|
||||
/// </summary>
|
||||
public async Task SendCommandAsync(byte command)
|
||||
{
|
||||
await _socket.SendAsync(new[] { command }, SocketFlags.None);
|
||||
if (IsActionCommand(command))
|
||||
{
|
||||
// [cmd][4 zero bytes] — signals no config override
|
||||
var frame = new byte[5];
|
||||
frame[0] = command;
|
||||
await _socket.SendAsync(frame, SocketFlags.None);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _socket.SendAsync(new[] { command }, SocketFlags.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends an action command with a HushConfig override payload.
|
||||
/// Format: [1 byte cmd][4-byte LE length][N bytes JSON]
|
||||
/// </summary>
|
||||
public async Task SendCommandWithConfigAsync(byte command, HushConfig config)
|
||||
{
|
||||
var jsonBytes = JsonSerializer.SerializeToUtf8Bytes(config, HushConfigContext.Default.HushConfig);
|
||||
var lenBytes = BitConverter.GetBytes(jsonBytes.Length);
|
||||
|
||||
var frame = new byte[1 + 4 + jsonBytes.Length];
|
||||
frame[0] = command;
|
||||
lenBytes.CopyTo(frame, 1);
|
||||
jsonBytes.CopyTo(frame, 5);
|
||||
|
||||
await _socket.SendAsync(frame, SocketFlags.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a request with a typed JSON payload (e.g. GENERATE_PROFILE).
|
||||
/// Format: [1 byte cmd][4-byte LE length][N bytes JSON]
|
||||
/// </summary>
|
||||
public async Task SendRequestAsync<TRequest>(byte command, TRequest payload, JsonTypeInfo<TRequest> typeInfo)
|
||||
{
|
||||
var jsonBytes = JsonSerializer.SerializeToUtf8Bytes(payload, typeInfo);
|
||||
var lenBytes = BitConverter.GetBytes(jsonBytes.Length);
|
||||
|
||||
var frame = new byte[1 + 4 + jsonBytes.Length];
|
||||
frame[0] = command;
|
||||
lenBytes.CopyTo(frame, 1);
|
||||
jsonBytes.CopyTo(frame, 5);
|
||||
|
||||
await _socket.SendAsync(frame, SocketFlags.None);
|
||||
}
|
||||
|
||||
public async Task<T?> ReceiveJsonAsync<T>(TimeSpan timeout)
|
||||
@@ -36,10 +87,10 @@ public class SocketClient : IAsyncDisposable
|
||||
var cts = new CancellationTokenSource(timeout);
|
||||
var buffer = new byte[4096];
|
||||
var bytesRead = await _socket.ReceiveAsync(buffer, SocketFlags.None, cts.Token);
|
||||
|
||||
|
||||
if (bytesRead == 0)
|
||||
return default;
|
||||
|
||||
|
||||
var json = Encoding.UTF8.GetString(buffer, 0, bytesRead);
|
||||
return (T?)JsonSerializer.Deserialize(json, typeof(T), DaemonJsonContext.Default);
|
||||
}
|
||||
@@ -61,4 +112,8 @@ public class SocketClient : IAsyncDisposable
|
||||
_socket.Dispose();
|
||||
await ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private static bool IsActionCommand(byte command) =>
|
||||
command is DaemonProtocol.START or DaemonProtocol.STOP
|
||||
or DaemonProtocol.ABORT or DaemonProtocol.TOGGLE;
|
||||
}
|
||||
|
||||
@@ -7,45 +7,99 @@ public class ConfigManager
|
||||
{
|
||||
private readonly string _configDir;
|
||||
private readonly string _configPath;
|
||||
private readonly string _profilesDir;
|
||||
|
||||
public ConfigManager()
|
||||
{
|
||||
var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
_configDir = Path.Combine(homeDir, ".config", "hush");
|
||||
_configPath = Path.Combine(_configDir, "config");
|
||||
_profilesDir = Path.Combine(_configDir, "profiles");
|
||||
}
|
||||
|
||||
public HushConfig Load()
|
||||
public HushConfig Load() => LoadFromFile(_configPath);
|
||||
|
||||
/// <summary>
|
||||
/// Loads the base config and merges the named profile on top of it.
|
||||
/// Only fields present in the profile file override the base config.
|
||||
/// Silently falls back to the base config on any error.
|
||||
/// </summary>
|
||||
public HushConfig LoadWithProfile(string profileName)
|
||||
{
|
||||
if (!File.Exists(_configPath))
|
||||
{
|
||||
return new HushConfig();
|
||||
}
|
||||
var config = LoadFromFile(_configPath);
|
||||
|
||||
var profilePath = Path.Combine(_profilesDir, profileName);
|
||||
if (!File.Exists(profilePath))
|
||||
return config;
|
||||
|
||||
try
|
||||
{
|
||||
var toml = File.ReadAllText(_configPath);
|
||||
var profileToml = File.ReadAllText(profilePath);
|
||||
var profileModel = Toml.ToModel<TomlTable>(profileToml);
|
||||
ApplyTomlFields(profileModel, config);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Silent fallback to base config on any profile error
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
public IEnumerable<string> ListProfiles()
|
||||
{
|
||||
if (!Directory.Exists(_profilesDir))
|
||||
return [];
|
||||
|
||||
return Directory.GetFiles(_profilesDir)
|
||||
.Select(Path.GetFileName)
|
||||
.Where(n => n != null)
|
||||
.Cast<string>()
|
||||
.Order();
|
||||
}
|
||||
|
||||
public string GetProfilePath(string profileName) => Path.Combine(_profilesDir, profileName);
|
||||
|
||||
public void EnsureProfilesDirExists() => Directory.CreateDirectory(_profilesDir);
|
||||
|
||||
public void Save(HushConfig config)
|
||||
{
|
||||
Directory.CreateDirectory(_configDir);
|
||||
|
||||
var model = new TomlTable
|
||||
{
|
||||
["groq_api_key"] = config.GroqApiKey,
|
||||
["fireworks_api_key"] = config.FireworksApiKey,
|
||||
|
||||
["llm_provider"] = config.LlmProvider,
|
||||
["whisper_provider"] = config.WhisperProvider,
|
||||
["typing_backend"] = config.TypingBackend,
|
||||
["audio_backend"] = config.AudioBackend,
|
||||
|
||||
["llm_model"] = config.LlmModel,
|
||||
["whisper_model"] = config.WhisperModel,
|
||||
|
||||
["min_recording_duration"] = config.MinRecordingDuration,
|
||||
["whisper_language"] = config.WhisperLanguage,
|
||||
["system_prompt"] = config.SystemPrompt
|
||||
};
|
||||
|
||||
var toml = Toml.FromModel(model);
|
||||
File.WriteAllText(_configPath, toml);
|
||||
}
|
||||
|
||||
private static HushConfig LoadFromFile(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
return new HushConfig();
|
||||
|
||||
try
|
||||
{
|
||||
var toml = File.ReadAllText(path);
|
||||
var model = Toml.ToModel<TomlTable>(toml);
|
||||
|
||||
|
||||
var config = new HushConfig();
|
||||
|
||||
if (model.TryGetValue("groq_api_key", out var groqKey)) config.GroqApiKey = groqKey.ToString() ?? string.Empty;
|
||||
if (model.TryGetValue("together_api_key", out var togetherKey)) config.TogetherApiKey = togetherKey.ToString() ?? string.Empty;
|
||||
if (model.TryGetValue("cerebras_api_key", out var cerebrasKey)) config.CerebrasApiKey = cerebrasKey.ToString() ?? string.Empty;
|
||||
if (model.TryGetValue("fireworks_api_key", out var fireworksKey)) config.FireworksApiKey = fireworksKey.ToString() ?? string.Empty;
|
||||
|
||||
if (model.TryGetValue("llm_provider", out var llmProvider)) config.LlmProvider = llmProvider.ToString() ?? "groq";
|
||||
if (model.TryGetValue("whisper_provider", out var whisperProvider)) config.WhisperProvider = whisperProvider.ToString() ?? "groq";
|
||||
if (model.TryGetValue("typing_backend", out var typingBackend)) config.TypingBackend = typingBackend.ToString() ?? "wtype";
|
||||
if (model.TryGetValue("audio_backend", out var audioBackend)) config.AudioBackend = audioBackend.ToString() ?? "pw-record";
|
||||
|
||||
if (model.TryGetValue("llm_model", out var llmModel)) config.LlmModel = llmModel.ToString() ?? "openai/gpt-oss-20b";
|
||||
if (model.TryGetValue("whisper_model", out var whisperModel)) config.WhisperModel = whisperModel.ToString() ?? "whisper-large-v3-turbo";
|
||||
if (model.TryGetValue("reasoning_effort", out var reasoningEffort)) config.ReasoningEffort = reasoningEffort.ToString() ?? "none";
|
||||
|
||||
if (model.TryGetValue("min_recording_duration", out var minDuration)) config.MinRecordingDuration = Convert.ToInt32(minDuration);
|
||||
if (model.TryGetValue("whisper_language", out var language)) config.WhisperLanguage = language.ToString() ?? string.Empty;
|
||||
|
||||
ApplyTomlFields(model, config);
|
||||
return config;
|
||||
}
|
||||
catch
|
||||
@@ -54,31 +108,21 @@ public class ConfigManager
|
||||
}
|
||||
}
|
||||
|
||||
public void Save(HushConfig config)
|
||||
private static void ApplyTomlFields(TomlTable model, HushConfig config)
|
||||
{
|
||||
Directory.CreateDirectory(_configDir);
|
||||
|
||||
var model = new TomlTable
|
||||
{
|
||||
["groq_api_key"] = config.GroqApiKey,
|
||||
["together_api_key"] = config.TogetherApiKey,
|
||||
["cerebras_api_key"] = config.CerebrasApiKey,
|
||||
["fireworks_api_key"] = config.FireworksApiKey,
|
||||
|
||||
["llm_provider"] = config.LlmProvider,
|
||||
["whisper_provider"] = config.WhisperProvider,
|
||||
["typing_backend"] = config.TypingBackend,
|
||||
["audio_backend"] = config.AudioBackend,
|
||||
|
||||
["llm_model"] = config.LlmModel,
|
||||
["whisper_model"] = config.WhisperModel,
|
||||
["reasoning_effort"] = config.ReasoningEffort,
|
||||
|
||||
["min_recording_duration"] = config.MinRecordingDuration,
|
||||
["whisper_language"] = config.WhisperLanguage
|
||||
};
|
||||
|
||||
var toml = Toml.FromModel(model);
|
||||
File.WriteAllText(_configPath, toml);
|
||||
if (model.TryGetValue("groq_api_key", out var groqKey)) config.GroqApiKey = groqKey.ToString() ?? string.Empty;
|
||||
if (model.TryGetValue("fireworks_api_key", out var fireworksKey)) config.FireworksApiKey = fireworksKey.ToString() ?? string.Empty;
|
||||
|
||||
if (model.TryGetValue("llm_provider", out var llmProvider)) config.LlmProvider = llmProvider.ToString() ?? "groq";
|
||||
if (model.TryGetValue("whisper_provider", out var whisperProvider)) config.WhisperProvider = whisperProvider.ToString() ?? "groq";
|
||||
if (model.TryGetValue("typing_backend", out var typingBackend)) config.TypingBackend = typingBackend.ToString() ?? "wtype";
|
||||
if (model.TryGetValue("audio_backend", out var audioBackend)) config.AudioBackend = audioBackend.ToString() ?? "pw-record";
|
||||
|
||||
if (model.TryGetValue("llm_model", out var llmModel)) config.LlmModel = llmModel.ToString() ?? "openai/gpt-oss-20b";
|
||||
if (model.TryGetValue("whisper_model", out var whisperModel)) config.WhisperModel = whisperModel.ToString() ?? "whisper-large-v3-turbo";
|
||||
|
||||
if (model.TryGetValue("min_recording_duration", out var minDuration)) config.MinRecordingDuration = Convert.ToInt32(minDuration);
|
||||
if (model.TryGetValue("whisper_language", out var language)) config.WhisperLanguage = language.ToString() ?? string.Empty;
|
||||
if (model.TryGetValue("system_prompt", out var systemPrompt)) config.SystemPrompt = systemPrompt.ToString() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,21 +5,21 @@ namespace Hush.Config;
|
||||
public class HushConfig
|
||||
{
|
||||
public string GroqApiKey { get; set; } = string.Empty;
|
||||
public string TogetherApiKey { get; set; } = string.Empty;
|
||||
public string CerebrasApiKey { get; set; } = string.Empty;
|
||||
public string FireworksApiKey { get; set; } = string.Empty;
|
||||
|
||||
|
||||
public string LlmProvider { get; set; } = "groq";
|
||||
public string WhisperProvider { get; set; } = "groq";
|
||||
public string TypingBackend { get; set; } = "wtype";
|
||||
public string AudioBackend { get; set; } = "pw-record";
|
||||
|
||||
|
||||
public string LlmModel { get; set; } = "openai/gpt-oss-20b";
|
||||
public string WhisperModel { get; set; } = "whisper-large-v3-turbo";
|
||||
public string ReasoningEffort { get; set; } = "none";
|
||||
|
||||
|
||||
public int MinRecordingDuration { get; set; } = 500;
|
||||
public string WhisperLanguage { get; set; } = string.Empty;
|
||||
|
||||
// Empty = use the built-in default transcription cleanup prompt
|
||||
public string SystemPrompt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(HushConfig))]
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Hush.Config;
|
||||
|
||||
namespace Hush.Daemon;
|
||||
|
||||
public static class DaemonProtocol
|
||||
{
|
||||
public const byte START = 1; // Start recording
|
||||
public const byte STOP = 2; // Stop recording, process, type
|
||||
public const byte ABORT = 3; // Cancel recording
|
||||
public const byte TOGGLE = 4; // Start if idle, stop if recording
|
||||
public const byte STATUS = 5; // Return state as JSON
|
||||
public const byte LATENCY_TEST = 6; // Run latency test, return timing JSON
|
||||
public const byte START = 1; // Start recording
|
||||
public const byte STOP = 2; // Stop recording, process, type
|
||||
public const byte ABORT = 3; // Cancel recording
|
||||
public const byte TOGGLE = 4; // Start if idle, stop if recording
|
||||
public const byte STATUS = 5; // Return state as JSON
|
||||
public const byte LATENCY_TEST = 6; // Run latency test, return timing JSON
|
||||
public const byte GENERATE_PROFILE = 7; // Generate a system prompt from a description
|
||||
}
|
||||
|
||||
public record LatencyResult(int SttMs, int LlmMs, int TotalMs);
|
||||
@@ -18,8 +20,15 @@ public record StatusResponse(string State, long? DurationMs = null);
|
||||
|
||||
public record ErrorResponse(string Error);
|
||||
|
||||
public record GenerateProfileRequest(string Description);
|
||||
|
||||
public record GenerateProfileResponse(string SystemPrompt);
|
||||
|
||||
[JsonSerializable(typeof(LatencyResult))]
|
||||
[JsonSerializable(typeof(StatusResponse))]
|
||||
[JsonSerializable(typeof(ErrorResponse))]
|
||||
[JsonSerializable(typeof(GenerateProfileRequest))]
|
||||
[JsonSerializable(typeof(GenerateProfileResponse))]
|
||||
[JsonSerializable(typeof(HushConfig))]
|
||||
[JsonSerializable(typeof(string))]
|
||||
public partial class DaemonJsonContext : JsonSerializerContext;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Hush.Config;
|
||||
|
||||
namespace Hush.Daemon;
|
||||
@@ -17,7 +19,7 @@ public class DaemonService
|
||||
public static async Task StartAsync()
|
||||
{
|
||||
var lockPath = GetLockFilePath();
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(lockPath)!);
|
||||
@@ -34,10 +36,7 @@ public class DaemonService
|
||||
if (File.Exists(socketPath))
|
||||
{
|
||||
try { File.Delete(socketPath); }
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
catch { /* ignored */ }
|
||||
}
|
||||
|
||||
var configManager = new ConfigManager();
|
||||
@@ -65,9 +64,7 @@ public class DaemonService
|
||||
finally
|
||||
{
|
||||
if (File.Exists(socketPath))
|
||||
{
|
||||
File.Delete(socketPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,122 +72,108 @@ public class DaemonService
|
||||
{
|
||||
try
|
||||
{
|
||||
var buffer = new byte[1];
|
||||
var bytesRead = await client.ReceiveAsync(buffer, SocketFlags.None);
|
||||
|
||||
if (bytesRead == 0)
|
||||
{
|
||||
client.Close();
|
||||
return;
|
||||
}
|
||||
// Read command byte
|
||||
var cmdBuffer = new byte[1];
|
||||
var bytesRead = await client.ReceiveAsync(cmdBuffer, SocketFlags.None);
|
||||
if (bytesRead == 0) { client.Close(); return; }
|
||||
|
||||
var cmd = buffer[0];
|
||||
var cmd = cmdBuffer[0];
|
||||
|
||||
switch (cmd)
|
||||
{
|
||||
case DaemonProtocol.START:
|
||||
await HandleStartAsync(orchestrator);
|
||||
break;
|
||||
case DaemonProtocol.STOP:
|
||||
await HandleStopAsync(orchestrator);
|
||||
break;
|
||||
case DaemonProtocol.ABORT:
|
||||
await HandleAbortAsync(orchestrator);
|
||||
break;
|
||||
case DaemonProtocol.TOGGLE:
|
||||
await HandleToggleAsync(orchestrator);
|
||||
break;
|
||||
case DaemonProtocol.STATUS:
|
||||
await HandleStatusAsync(client, orchestrator);
|
||||
break;
|
||||
case DaemonProtocol.LATENCY_TEST:
|
||||
await HandleLatencyTestAsync(client, orchestrator);
|
||||
{
|
||||
// These commands carry an optional HushConfig payload: [4-byte LE length][JSON]
|
||||
var overrideConfig = await ReadConfigPayloadAsync(client);
|
||||
switch (cmd)
|
||||
{
|
||||
case DaemonProtocol.START: await HandleStartAsync(orchestrator); break;
|
||||
case DaemonProtocol.STOP: await HandleStopAsync(orchestrator, overrideConfig); break;
|
||||
case DaemonProtocol.ABORT: await HandleAbortAsync(orchestrator); break;
|
||||
case DaemonProtocol.TOGGLE: await HandleToggleAsync(orchestrator, overrideConfig); break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case DaemonProtocol.STATUS: await HandleStatusAsync(client, orchestrator); break;
|
||||
case DaemonProtocol.LATENCY_TEST: await HandleLatencyTestAsync(client, orchestrator); break;
|
||||
case DaemonProtocol.GENERATE_PROFILE: await HandleGenerateProfileAsync(client, orchestrator); break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception ex) { Console.WriteLine($"HandleClient error: {ex.Message}"); }
|
||||
finally { client.Close(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the optional HushConfig payload that follows action commands.
|
||||
/// Format: [4-byte LE int32 length][N bytes JSON]. Returns null if length == 0.
|
||||
/// </summary>
|
||||
private static async Task<HushConfig?> ReadConfigPayloadAsync(Socket client)
|
||||
{
|
||||
var lenBuffer = new byte[4];
|
||||
var totalRead = 0;
|
||||
while (totalRead < 4)
|
||||
{
|
||||
Console.WriteLine($"HandleClient error: {ex.Message}");
|
||||
var n = await client.ReceiveAsync(lenBuffer.AsMemory(totalRead), SocketFlags.None);
|
||||
if (n == 0) return null;
|
||||
totalRead += n;
|
||||
}
|
||||
finally
|
||||
|
||||
var length = BitConverter.ToInt32(lenBuffer, 0);
|
||||
if (length == 0) return null;
|
||||
|
||||
var jsonBuffer = new byte[length];
|
||||
totalRead = 0;
|
||||
while (totalRead < length)
|
||||
{
|
||||
client.Close();
|
||||
var n = await client.ReceiveAsync(jsonBuffer.AsMemory(totalRead), SocketFlags.None);
|
||||
if (n == 0) break;
|
||||
totalRead += n;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize(jsonBuffer, DaemonJsonContext.Default.HushConfig);
|
||||
}
|
||||
|
||||
private static async Task HandleStartAsync(Orchestrator orchestrator)
|
||||
{
|
||||
if (orchestrator.IsRecording)
|
||||
{
|
||||
Console.WriteLine("Already recording");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await orchestrator.StartRecordingAsync();
|
||||
Console.WriteLine("Recording started");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Failed to start recording: {ex.Message}");
|
||||
}
|
||||
if (orchestrator.IsRecording) { Console.WriteLine("Already recording"); return; }
|
||||
try { await orchestrator.StartRecordingAsync(); Console.WriteLine("Recording started"); }
|
||||
catch (Exception ex) { Console.WriteLine($"Failed to start recording: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static async Task HandleStopAsync(Orchestrator orchestrator)
|
||||
private static async Task HandleStopAsync(Orchestrator orchestrator, HushConfig? overrideConfig)
|
||||
{
|
||||
if (!orchestrator.IsRecording)
|
||||
{
|
||||
Console.WriteLine("Not recording");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await orchestrator.StopAndProcessAsync();
|
||||
Console.WriteLine("Recording stopped and processed");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Failed to stop recording: {ex.Message}");
|
||||
}
|
||||
if (!orchestrator.IsRecording) { Console.WriteLine("Not recording"); return; }
|
||||
try { await orchestrator.StopAndProcessAsync(overrideConfig); Console.WriteLine("Recording stopped and processed"); }
|
||||
catch (Exception ex) { Console.WriteLine($"Failed to stop recording: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static async Task HandleAbortAsync(Orchestrator orchestrator)
|
||||
{
|
||||
if (!orchestrator.IsRecording)
|
||||
{
|
||||
Console.WriteLine("Not recording");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!orchestrator.IsRecording) { Console.WriteLine("Not recording"); return; }
|
||||
await orchestrator.AbortAsync();
|
||||
Console.WriteLine("Recording aborted");
|
||||
}
|
||||
|
||||
private static async Task HandleToggleAsync(Orchestrator orchestrator)
|
||||
private static async Task HandleToggleAsync(Orchestrator orchestrator, HushConfig? overrideConfig)
|
||||
{
|
||||
if (orchestrator.IsRecording)
|
||||
{
|
||||
await HandleStopAsync(orchestrator);
|
||||
}
|
||||
else
|
||||
{
|
||||
await HandleStartAsync(orchestrator);
|
||||
}
|
||||
if (orchestrator.IsRecording) await HandleStopAsync(orchestrator, overrideConfig);
|
||||
else await HandleStartAsync(orchestrator);
|
||||
}
|
||||
|
||||
private static async Task HandleStatusAsync(Socket client, Orchestrator orchestrator)
|
||||
{
|
||||
var isRecording = orchestrator.IsRecording;
|
||||
var durationMs = orchestrator.GetRecordingDuration()?.TotalMilliseconds;
|
||||
|
||||
var responseObj = isRecording
|
||||
var durationMs = orchestrator.GetRecordingDuration()?.TotalMilliseconds;
|
||||
|
||||
var responseObj = isRecording
|
||||
? new StatusResponse("recording", (long?)durationMs)
|
||||
: new StatusResponse("idle");
|
||||
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(responseObj, DaemonJsonContext.Default.StatusResponse);
|
||||
var response = System.Text.Encoding.UTF8.GetBytes(json);
|
||||
|
||||
var json = JsonSerializer.Serialize(responseObj, DaemonJsonContext.Default.StatusResponse);
|
||||
var response = Encoding.UTF8.GetBytes(json);
|
||||
await client.SendAsync(response, SocketFlags.None);
|
||||
}
|
||||
|
||||
@@ -198,16 +181,59 @@ public class DaemonService
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await orchestrator.RunLatencyTestAsync();
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(result, DaemonJsonContext.Default.LatencyResult);
|
||||
var response = System.Text.Encoding.UTF8.GetBytes(json);
|
||||
var result = await orchestrator.RunLatencyTestAsync();
|
||||
var json = JsonSerializer.Serialize(result, DaemonJsonContext.Default.LatencyResult);
|
||||
var response = Encoding.UTF8.GetBytes(json);
|
||||
await client.SendAsync(response, SocketFlags.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var error = new ErrorResponse(ex.Message);
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(error, DaemonJsonContext.Default.ErrorResponse);
|
||||
var response = System.Text.Encoding.UTF8.GetBytes(json);
|
||||
var error = new ErrorResponse(ex.Message);
|
||||
var json = JsonSerializer.Serialize(error, DaemonJsonContext.Default.ErrorResponse);
|
||||
var response = Encoding.UTF8.GetBytes(json);
|
||||
await client.SendAsync(response, SocketFlags.None);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task HandleGenerateProfileAsync(Socket client, Orchestrator orchestrator)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Read GenerateProfileRequest payload: [4-byte LE length][JSON]
|
||||
var lenBuffer = new byte[4];
|
||||
var totalRead = 0;
|
||||
while (totalRead < 4)
|
||||
{
|
||||
var n = await client.ReceiveAsync(lenBuffer.AsMemory(totalRead), SocketFlags.None);
|
||||
if (n == 0) throw new InvalidOperationException("Connection closed before length prefix");
|
||||
totalRead += n;
|
||||
}
|
||||
|
||||
var length = BitConverter.ToInt32(lenBuffer, 0);
|
||||
var jsonBuffer = new byte[length];
|
||||
totalRead = 0;
|
||||
while (totalRead < length)
|
||||
{
|
||||
var n = await client.ReceiveAsync(jsonBuffer.AsMemory(totalRead), SocketFlags.None);
|
||||
if (n == 0) break;
|
||||
totalRead += n;
|
||||
}
|
||||
|
||||
var request = JsonSerializer.Deserialize(jsonBuffer, DaemonJsonContext.Default.GenerateProfileRequest)
|
||||
?? throw new InvalidOperationException("Failed to deserialize GenerateProfileRequest");
|
||||
|
||||
var systemPrompt = await orchestrator.GenerateProfilePromptAsync(request.Description);
|
||||
|
||||
var responseObj = new GenerateProfileResponse(systemPrompt);
|
||||
var json = JsonSerializer.Serialize(responseObj, DaemonJsonContext.Default.GenerateProfileResponse);
|
||||
var response = Encoding.UTF8.GetBytes(json);
|
||||
await client.SendAsync(response, SocketFlags.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var error = new ErrorResponse(ex.Message);
|
||||
var json = JsonSerializer.Serialize(error, DaemonJsonContext.Default.ErrorResponse);
|
||||
var response = Encoding.UTF8.GetBytes(json);
|
||||
await client.SendAsync(response, SocketFlags.None);
|
||||
}
|
||||
}
|
||||
@@ -215,7 +241,7 @@ public class DaemonService
|
||||
private static string GetLockFilePath()
|
||||
{
|
||||
var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var appDir = Path.Combine(homeDir, "hush");
|
||||
var appDir = Path.Combine(homeDir, "hush");
|
||||
return Path.Combine(appDir, "daemon.lock");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ public class Orchestrator
|
||||
private bool _isRecording;
|
||||
private readonly Lock _lock = new();
|
||||
|
||||
|
||||
|
||||
public Orchestrator(ConfigManager configManager)
|
||||
{
|
||||
_configManager = configManager;
|
||||
@@ -61,7 +63,7 @@ public class Orchestrator
|
||||
return _recorder.StartRecording(_recordingPath);
|
||||
}
|
||||
|
||||
public async Task StopAndProcessAsync()
|
||||
public async Task StopAndProcessAsync(HushConfig? overrideConfig = null)
|
||||
{
|
||||
string? recordingPath;
|
||||
DateTime? recordingStartTime;
|
||||
@@ -86,7 +88,7 @@ public class Orchestrator
|
||||
|
||||
try
|
||||
{
|
||||
var config = _configManager.Load();
|
||||
var config = overrideConfig ?? _configManager.Load();
|
||||
|
||||
var recordingDuration = recordingStartTime.HasValue
|
||||
? DateTime.UtcNow - recordingStartTime.Value
|
||||
@@ -101,7 +103,7 @@ public class Orchestrator
|
||||
|
||||
var transcription = await TranscribeAsync(recordingPath, config);
|
||||
var processedText = await ProcessWithLlmAsync(transcription, config);
|
||||
|
||||
|
||||
await TypeAsync(processedText, config);
|
||||
|
||||
File.Delete(recordingPath);
|
||||
@@ -138,32 +140,61 @@ public class Orchestrator
|
||||
private async Task<string> TranscribeAsync(string path, HushConfig config)
|
||||
{
|
||||
var provider = GetAudioToTextProvider(config);
|
||||
|
||||
|
||||
await using var stream = File.OpenRead(path);
|
||||
return await provider.TranscribeAsync(stream, config.WhisperModel);
|
||||
return await provider.TranscribeAsync(
|
||||
stream,
|
||||
config.WhisperModel,
|
||||
language: string.IsNullOrEmpty(config.WhisperLanguage) ? null : config.WhisperLanguage);
|
||||
}
|
||||
|
||||
private const string DefaultSystemPrompt =
|
||||
"""
|
||||
You are a transcription post-processor. Your task is to clean up raw speech-to-text output and return polished, ready-to-type text.
|
||||
|
||||
Rules:
|
||||
- Detect the language of the transcription and process it entirely in that language — do not translate
|
||||
- Fix grammar, spelling, and punctuation errors introduced by the speech recognizer, following the conventions of the detected language
|
||||
- Capitalize sentences and proper nouns appropriately for the detected language
|
||||
- Remove filler words and false starts appropriate to the detected language (e.g. "um", "uh", "like" in English; "euh", "bah" in French; "äh", "ähm" in German; "eh", "tipo" in Spanish/Italian)
|
||||
- Preserve the speaker's original intent, vocabulary choices, and tone
|
||||
- Do not add, remove, or reinterpret content beyond what was said
|
||||
- Do not include any explanation, preamble, or metadata — output only the corrected text
|
||||
- If the input is empty or unintelligible, return an empty string
|
||||
""";
|
||||
|
||||
private async Task<string> ProcessWithLlmAsync(string text, HushConfig config)
|
||||
{
|
||||
var provider = GetTextProvider(config);
|
||||
|
||||
var prompt = $"""
|
||||
You are a transcription post-processor. Your task is to clean up raw speech-to-text output and return polished, ready-to-type text.
|
||||
var systemPrompt = string.IsNullOrWhiteSpace(config.SystemPrompt)
|
||||
? DefaultSystemPrompt
|
||||
: config.SystemPrompt;
|
||||
|
||||
Rules:
|
||||
- Detect the language of the transcription and process it entirely in that language — do not translate
|
||||
- Fix grammar, spelling, and punctuation errors introduced by the speech recognizer, following the conventions of the detected language
|
||||
- Capitalize sentences and proper nouns appropriately for the detected language
|
||||
- Remove filler words and false starts appropriate to the detected language (e.g. "um", "uh", "like" in English; "euh", "bah" in French; "äh", "ähm" in German; "eh", "tipo" in Spanish/Italian)
|
||||
- Preserve the speaker's original intent, vocabulary choices, and tone
|
||||
- Do not add, remove, or reinterpret content beyond what was said
|
||||
- Do not include any explanation, preamble, or metadata — output only the corrected text
|
||||
- If the input is empty or unintelligible, return an empty string
|
||||
return await provider.CompleteTextAsync(systemPrompt, text, config.LlmModel);
|
||||
}
|
||||
|
||||
Raw transcription: {text}
|
||||
""";
|
||||
|
||||
return await provider.CompleteTextAsync(prompt, config.LlmModel);
|
||||
public async Task<string> GenerateProfilePromptAsync(string description)
|
||||
{
|
||||
var config = _configManager.Load();
|
||||
var provider = GetTextProvider(config);
|
||||
|
||||
const string systemPrompt =
|
||||
"""
|
||||
You are a configuration assistant for Hush, a Linux speech-to-text post-processor.
|
||||
Hush records the user's voice, transcribes it with Whisper, then passes the transcription
|
||||
to an LLM using a system prompt you will write.
|
||||
|
||||
Given the user's description of what they want the profile to do, write a precise, concise
|
||||
system prompt that instructs the LLM how to transform the raw transcription.
|
||||
|
||||
Rules:
|
||||
- Output only the system prompt text, nothing else
|
||||
- Do not include meta-commentary, labels, or markdown formatting
|
||||
- The prompt must be self-contained and unambiguous
|
||||
- Always end with an instruction to output only the final result with no explanation
|
||||
""";
|
||||
|
||||
return await provider.CompleteTextAsync(systemPrompt, description, config.LlmModel);
|
||||
}
|
||||
|
||||
private async Task TypeAsync(string text, HushConfig config)
|
||||
|
||||
@@ -11,7 +11,7 @@ public class WtypeInput : ITextInput
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "wtype",
|
||||
Arguments = $"\"{text}\"",
|
||||
Arguments = $"-d 5 \"{text}\"",
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
|
||||
@@ -15,5 +15,6 @@ public interface IAudioToTextProvider
|
||||
Task<string> TranscribeAsync(
|
||||
Stream audioStream,
|
||||
string modelName,
|
||||
string? language = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,21 @@
|
||||
namespace Hush.Providers.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for text generation with both synchronous and streaming capabilities.
|
||||
/// Interface for text generation.
|
||||
/// </summary>
|
||||
public interface ITextStreamingProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates text completion for a given prompt.
|
||||
/// Generates a text completion using a system prompt and a user message.
|
||||
/// </summary>
|
||||
/// <param name="prompt">The input prompt</param>
|
||||
/// <param name="systemPrompt">The system prompt that instructs the model how to behave</param>
|
||||
/// <param name="userMessage">The user message to process</param>
|
||||
/// <param name="modelName">The model name to use (e.g., llama-3.3-70b-versatile)</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>The generated text</returns>
|
||||
Task<string> CompleteTextAsync(
|
||||
string prompt,
|
||||
string modelName,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Streams text generation for a given prompt.
|
||||
/// </summary>
|
||||
/// <param name="prompt">The input prompt</param>
|
||||
/// <param name="modelName">The model name to use (e.g., llama-3.3-70b-versatile)</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Async enumerable of text chunks</returns>
|
||||
IAsyncEnumerable<string> StreamTextAsync(
|
||||
string prompt,
|
||||
string systemPrompt,
|
||||
string userMessage,
|
||||
string modelName,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ public class FireworksProvider : IAudioToTextProvider, ITextStreamingProvider
|
||||
public async Task<string> TranscribeAsync(
|
||||
Stream audioStream,
|
||||
string modelName,
|
||||
string? language = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(audioStream);
|
||||
@@ -45,7 +46,7 @@ public class FireworksProvider : IAudioToTextProvider, ITextStreamingProvider
|
||||
? TRANSCRIPTION_ENDPOINT_TURBO
|
||||
: TRANSCRIPTION_ENDPOINT_PROD;
|
||||
|
||||
var request = new TranscriptionRequest { Model = modelName };
|
||||
var request = new TranscriptionRequest { Model = modelName, Language = language };
|
||||
|
||||
using var content = new MultipartFormDataContent();
|
||||
content.Add(new StreamContent(audioStream), "file", "audio.wav");
|
||||
@@ -84,12 +85,13 @@ public class FireworksProvider : IAudioToTextProvider, ITextStreamingProvider
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string> CompleteTextAsync(
|
||||
string prompt,
|
||||
string systemPrompt,
|
||||
string userMessage,
|
||||
string modelName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(prompt))
|
||||
throw new ArgumentException("Prompt is required", nameof(prompt));
|
||||
if (string.IsNullOrWhiteSpace(systemPrompt))
|
||||
throw new ArgumentException("System prompt is required", nameof(systemPrompt));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(modelName))
|
||||
throw new ArgumentException("Model name is required", nameof(modelName));
|
||||
@@ -97,7 +99,11 @@ public class FireworksProvider : IAudioToTextProvider, ITextStreamingProvider
|
||||
var request = new ChatCompletionRequest
|
||||
{
|
||||
Model = modelName,
|
||||
Messages = new List<Message> { new() { Role = "user", Content = prompt } }
|
||||
Messages = new List<Message>
|
||||
{
|
||||
new() { Role = "system", Content = systemPrompt },
|
||||
new() { Role = "user", Content = userMessage }
|
||||
}
|
||||
};
|
||||
|
||||
var jsonContent = new StringContent(
|
||||
@@ -126,86 +132,5 @@ public class FireworksProvider : IAudioToTextProvider, ITextStreamingProvider
|
||||
return result.Choices[0].Message.Content;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<string> StreamTextAsync(
|
||||
string prompt,
|
||||
string modelName,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(prompt))
|
||||
throw new ArgumentException("Prompt is required", nameof(prompt));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(modelName))
|
||||
throw new ArgumentException("Model name is required", nameof(modelName));
|
||||
|
||||
var request = new ChatCompletionRequest
|
||||
{
|
||||
Model = modelName,
|
||||
Stream = true,
|
||||
Messages = new List<Message> { new() { Role = "user", Content = prompt } }
|
||||
};
|
||||
|
||||
var jsonContent = new StringContent(
|
||||
JsonSerializer.Serialize(request, JsonSourceGeneration.Default.ChatCompletionRequest),
|
||||
Encoding.UTF8,
|
||||
"application/json");
|
||||
|
||||
var httpRequest = new HttpRequestMessage(HttpMethod.Post, CHAT_COMPLETION_ENDPOINT)
|
||||
{
|
||||
Content = jsonContent
|
||||
};
|
||||
|
||||
httpRequest.Headers.TryAddWithoutValidation("Authorization", _apiKey);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
using var reader = new StreamReader(stream);
|
||||
|
||||
string? line;
|
||||
while ((line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false)) != null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data: "))
|
||||
continue;
|
||||
|
||||
var data = line.Substring(6).Trim(); // Remove "data: " prefix
|
||||
|
||||
if (data == "[DONE]")
|
||||
break;
|
||||
|
||||
var text = ParseTextFromStreamData(data);
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
yield return text;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ParseTextFromStreamData(string data)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var jsonDoc = JsonDocument.Parse(data);
|
||||
var choices = jsonDoc.RootElement.GetProperty("choices");
|
||||
var choice = choices[0];
|
||||
|
||||
if (choice.TryGetProperty("delta", out var delta))
|
||||
{
|
||||
if (delta.TryGetProperty("content", out var content))
|
||||
{
|
||||
return content.GetString();
|
||||
}
|
||||
}
|
||||
else if (choice.TryGetProperty("text", out var text))
|
||||
{
|
||||
return text.GetString();
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Skip malformed JSON chunks
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ public class GroqProvider : IAudioToTextProvider, ITextStreamingProvider
|
||||
public async Task<string> TranscribeAsync(
|
||||
Stream audioStream,
|
||||
string modelName,
|
||||
string? language = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(audioStream);
|
||||
@@ -40,7 +41,7 @@ public class GroqProvider : IAudioToTextProvider, ITextStreamingProvider
|
||||
if (string.IsNullOrWhiteSpace(modelName))
|
||||
throw new ArgumentException("Model name is required", nameof(modelName));
|
||||
|
||||
var request = new TranscriptionRequest { Model = modelName };
|
||||
var request = new TranscriptionRequest { Model = modelName, Language = language };
|
||||
|
||||
using var content = new MultipartFormDataContent();
|
||||
content.Add(new StreamContent(audioStream), "file", "audio.wav");
|
||||
@@ -79,20 +80,24 @@ public class GroqProvider : IAudioToTextProvider, ITextStreamingProvider
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string> CompleteTextAsync(
|
||||
string prompt,
|
||||
string systemPrompt,
|
||||
string userMessage,
|
||||
string modelName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(prompt))
|
||||
throw new ArgumentException("Prompt is required", nameof(prompt));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(systemPrompt))
|
||||
throw new ArgumentException("System prompt is required", nameof(systemPrompt));
|
||||
if (string.IsNullOrWhiteSpace(modelName))
|
||||
throw new ArgumentException("Model name is required", nameof(modelName));
|
||||
|
||||
var request = new ChatCompletionRequest
|
||||
{
|
||||
Model = modelName,
|
||||
Messages = new List<Models.Request.Message> { new() { Role = "user", Content = prompt } }
|
||||
Model = modelName,
|
||||
Messages = new List<Models.Request.Message>
|
||||
{
|
||||
new() { Role = "system", Content = systemPrompt },
|
||||
new() { Role = "user", Content = userMessage }
|
||||
}
|
||||
};
|
||||
|
||||
var jsonContent = new StringContent(
|
||||
@@ -121,86 +126,4 @@ public class GroqProvider : IAudioToTextProvider, ITextStreamingProvider
|
||||
return result.Choices[0].Message.Content;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<string> StreamTextAsync(
|
||||
string prompt,
|
||||
string modelName,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(prompt))
|
||||
throw new ArgumentException("Prompt is required", nameof(prompt));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(modelName))
|
||||
throw new ArgumentException("Model name is required", nameof(modelName));
|
||||
|
||||
var request = new ChatCompletionRequest
|
||||
{
|
||||
Model = modelName,
|
||||
Stream = true,
|
||||
Messages = new List<Models.Request.Message> { new() { Role = "user", Content = prompt } }
|
||||
};
|
||||
|
||||
var jsonContent = new StringContent(
|
||||
JsonSerializer.Serialize(request, JsonSourceGeneration.Default.ChatCompletionRequest),
|
||||
Encoding.UTF8,
|
||||
"application/json");
|
||||
|
||||
var httpRequest = new HttpRequestMessage(HttpMethod.Post, CHAT_COMPLETION_ENDPOINT)
|
||||
{
|
||||
Content = jsonContent
|
||||
};
|
||||
|
||||
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
using var reader = new StreamReader(stream);
|
||||
|
||||
string? line;
|
||||
while ((line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false)) != null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data: "))
|
||||
continue;
|
||||
|
||||
var data = line.Substring(6).Trim(); // Remove "data: " prefix
|
||||
|
||||
if (data == "[DONE]")
|
||||
break;
|
||||
|
||||
var text = ParseTextFromStreamData(data);
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
yield return text;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ParseTextFromStreamData(string data)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var jsonDoc = JsonDocument.Parse(data);
|
||||
var choices = jsonDoc.RootElement.GetProperty("choices");
|
||||
var choice = choices[0];
|
||||
|
||||
if (choice.TryGetProperty("delta", out var delta))
|
||||
{
|
||||
if (delta.TryGetProperty("content", out var content))
|
||||
{
|
||||
return content.GetString();
|
||||
}
|
||||
}
|
||||
else if (choice.TryGetProperty("text", out var text))
|
||||
{
|
||||
return text.GetString();
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Skip malformed JSON chunks
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Hush
|
||||
|
||||
A Linux speech-to-text daemon that records audio, transcribes it via the Groq Whisper API, optionally cleans up the text with an LLM, and types the result into whatever window currently has focus.
|
||||
A Linux speech-to-text daemon that records audio, transcribes it via the Groq or Fireworks Whisper API, cleans up the text with an LLM, and types the result into whatever window currently has focus.
|
||||
|
||||
The intended workflow: bind `hush toggle` to a global hotkey, press to start recording, press again to stop, and the transcribed text appears in your active application.
|
||||
The intended workflow: bind `hush toggle` to a global hotkey, press to start recording, press again to stop, and the transcribed text appears in your active application. Use profiles to switch behaviour per hotkey — e.g. one binding for normal dictation, another for translating speech into a shell command.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -11,7 +11,7 @@ The intended workflow: bind `hush toggle` to a global hotkey, press to start rec
|
||||
- [pw-record](https://pipewire.org/) (PipeWire) or `ffmpeg` (PulseAudio) — audio capture
|
||||
- [wtype](https://github.com/atx/wtype) (Wayland) or [xdotool](https://github.com/jordansissel/xdotool) (X11) — text injection
|
||||
- `notify-send` — desktop notifications
|
||||
- A [Groq API key](https://console.groq.com/)
|
||||
- A [Groq](https://console.groq.com/) or [Fireworks](https://fireworks.ai/) API key
|
||||
|
||||
**Build dependencies:**
|
||||
|
||||
@@ -54,6 +54,16 @@ Config is stored at `~/.config/hush/config` in TOML format.
|
||||
| `hush latency-test` | Measure STT and LLM round-trip latency |
|
||||
| `hush daemon` | Run the daemon in the foreground |
|
||||
| `hush setup` | Interactive configuration wizard |
|
||||
| `hush profiles list` | List all profiles |
|
||||
| `hush profiles get <name>` | Print the contents of a profile |
|
||||
| `hush profiles new <name>` | Create a new profile (manual or AI-generated) |
|
||||
| `hush profiles edit <name>` | Open a profile in `$EDITOR` |
|
||||
|
||||
`hush toggle` and `hush stop` accept a `--profile <name>` flag to apply a named profile when processing the recording:
|
||||
|
||||
```bash
|
||||
hush toggle --profile cmd
|
||||
```
|
||||
|
||||
## Building Manually
|
||||
|
||||
@@ -70,9 +80,43 @@ Key settings in `~/.config/hush/config`:
|
||||
| Key | Default | Description |
|
||||
|---|---|---|
|
||||
| `groq_api_key` | | Groq API key |
|
||||
| `fireworks_api_key` | | Fireworks API key |
|
||||
| `whisper_provider` | `groq` | `groq` or `fireworks` |
|
||||
| `llm_provider` | `groq` | `groq` or `fireworks` |
|
||||
| `whisper_model` | `whisper-large-v3-turbo` | Whisper model to use |
|
||||
| `llm_model` | `openai/gpt-oss-20b` | LLM model for text cleanup |
|
||||
| `audio_backend` | `pw-record` | `pw-record` or `ffmpeg` |
|
||||
| `typing_backend` | `wtype` | `wtype` or `xdotool` |
|
||||
| `whisper_language` | | Optional ISO 639-1 language code |
|
||||
| `whisper_language` | | Optional ISO 639-1 language code (e.g. `en`) |
|
||||
| `min_recording_duration` | `500` | Minimum recording length in ms |
|
||||
| `system_prompt` | | Custom LLM system prompt (empty = built-in default) |
|
||||
|
||||
## Profiles
|
||||
|
||||
Profiles let you switch Hush's behaviour on a per-hotkey basis without touching the main config or restarting the daemon. A profile is a partial TOML file stored in `~/.config/hush/profiles/` — only the fields you list override the base config.
|
||||
|
||||
Create a profile interactively:
|
||||
|
||||
```bash
|
||||
hush profiles new cmd
|
||||
```
|
||||
|
||||
You will be asked whether to write the prompt manually or have the AI generate it from a description. The profile opens in `$EDITOR` for final review.
|
||||
|
||||
Example profile at `~/.config/hush/profiles/cmd`:
|
||||
|
||||
```toml
|
||||
whisper_language = "en"
|
||||
system_prompt = """
|
||||
Convert the spoken input into a single valid bash command. Output only the raw
|
||||
command with no explanation, no markdown, and no backticks. If the input is
|
||||
unclear, output an empty string.
|
||||
"""
|
||||
```
|
||||
|
||||
Bind a second hotkey to use it:
|
||||
|
||||
```
|
||||
Super+R → hush toggle
|
||||
Super+Shift+R → hush toggle --profile cmd
|
||||
```
|
||||
|
||||
@@ -20,6 +20,7 @@ _hush() {
|
||||
'latency-test:Run a full STT+LLM round-trip latency test'
|
||||
'setup:Interactive configuration wizard'
|
||||
'show:Display current configuration'
|
||||
'profiles:Manage configuration profiles'
|
||||
)
|
||||
_describe 'command' commands
|
||||
;;
|
||||
@@ -30,9 +31,31 @@ _hush() {
|
||||
'--plain[Show plain key=value pairs instead of a table]' \
|
||||
'--json[Show raw JSON output]'
|
||||
;;
|
||||
toggle|stop)
|
||||
_arguments \
|
||||
'--profile[Profile name to apply when processing]:profile:_hush_profiles' \
|
||||
'-p[Profile name to apply when processing]:profile:_hush_profiles'
|
||||
;;
|
||||
profiles)
|
||||
local subcommands=(
|
||||
'list:List all available profiles'
|
||||
'get:Print the contents of a profile'
|
||||
'new:Create a new profile'
|
||||
'edit:Open a profile in $EDITOR'
|
||||
)
|
||||
_describe 'profiles subcommand' subcommands
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
_hush_profiles() {
|
||||
local profiles_dir="${HOME}/.config/hush/profiles"
|
||||
if [[ -d "$profiles_dir" ]]; then
|
||||
local profiles=("${profiles_dir}"/*(N:t))
|
||||
_describe 'profile' profiles
|
||||
fi
|
||||
}
|
||||
|
||||
_hush "$@"
|
||||
|
||||
Reference in New Issue
Block a user