add configuration profiles with per-invocation --profile flag
- Add SystemPrompt field to HushConfig (empty = built-in default) - Refactor ConfigManager: extract ApplyTomlFields, add LoadWithProfile(), ListProfiles(), GetProfilePath(), EnsureProfilesDirExists(); remove HUSH_PROFILE env-var logic (profiles are now resolved by the CLI) - Extend socket protocol: action commands (START/STOP/TOGGLE/ABORT) now carry a [4-byte LE length][optional HushConfig JSON] payload so the CLI can pass a per-invocation config override without restarting the daemon - Add GENERATE_PROFILE (cmd 7) socket command: CLI sends a description, daemon calls the LLM and returns a generated system prompt - Orchestrator: StopAndProcessAsync accepts optional HushConfig override; ProcessWithLlmAsync uses proper system/user chat roles and respects config.SystemPrompt; add GenerateProfilePromptAsync - Split CompleteTextAsync signature to (systemPrompt, userMessage, model) across ITextStreamingProvider, GroqProvider, FireworksProvider - Add --profile/-p flag to hush toggle and hush stop - Add hush profiles subcommand: list, get, new (manual or AI-generated), edit
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user