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:
2026-03-23 00:38:29 +01:00
parent 70e784a1cc
commit eb0619dea2
14 changed files with 659 additions and 372 deletions
+94 -50
View File
@@ -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;
}
}
+6 -6
View File
@@ -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))]