diff --git a/Commands/HistoryCommand.cs b/Commands/HistoryCommand.cs new file mode 100644 index 0000000..57f92e9 --- /dev/null +++ b/Commands/HistoryCommand.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using System.CommandLine; +using Spectre.Console; +using Toak.Core; + +namespace Toak.Commands; + +public static class HistoryCommand +{ + public static async Task ExecuteAsync(int count, string grep, string export, bool shred, bool verbose) + { + Logger.Verbose = verbose; + + if (shred) + { + HistoryManager.Shred(); + AnsiConsole.MarkupLine("[green]History successfully shredded.[/]"); + return; + } + + var entries = HistoryManager.LoadEntries(); + if (entries.Count == 0) + { + AnsiConsole.MarkupLine("[yellow]No history found.[/]"); + return; + } + + // Apply grep filter + if (!string.IsNullOrWhiteSpace(grep)) + { + entries = entries.Where(e => + e.RawTranscript.Contains(grep, StringComparison.OrdinalIgnoreCase) || + e.RefinedText.Contains(grep, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (entries.Count == 0) + { + AnsiConsole.MarkupLine($"[yellow]No history entries match '{grep}'.[/]"); + return; + } + } + + // Get last N + entries = entries.OrderBy(e => e.Timestamp).TakeLast(count).ToList(); + + // Export + if (!string.IsNullOrWhiteSpace(export)) + { + try + { + using var writer = new StreamWriter(export); + writer.WriteLine($"# Toak Transcriptions - {DateTime.Now:yyyy-MM-dd}"); + writer.WriteLine(); + + foreach (var entry in entries) + { + writer.WriteLine($"## {entry.Timestamp.ToLocalTime():HH:mm:ss}"); + writer.WriteLine(entry.RefinedText); + writer.WriteLine(); + } + + AnsiConsole.MarkupLine($"[green]Successfully exported {entries.Count} entries to {export}[/]"); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]Error exporting history:[/] {ex.Message}"); + } + return; + } + + // Display + var table = new Table().Border(TableBorder.Rounded); + table.AddColumn("Time"); + table.AddColumn("Skill"); + table.AddColumn("Text"); + + foreach (var entry in entries) + { + table.AddRow( + $"[dim]{entry.Timestamp.ToLocalTime():HH:mm:ss}[/]", + entry.SkillName != null ? $"[blue]{entry.SkillName}[/]" : "-", + entry.RefinedText.Replace("[", "[[").Replace("]", "]]") // Escape spectre markup + ); + } + + AnsiConsole.Write(table); + } +} diff --git a/Commands/StatsCommand.cs b/Commands/StatsCommand.cs new file mode 100644 index 0000000..9915980 --- /dev/null +++ b/Commands/StatsCommand.cs @@ -0,0 +1,56 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using System.CommandLine; +using Spectre.Console; +using Toak.Core; + +namespace Toak.Commands; + +public static class StatsCommand +{ + public static async Task ExecuteAsync(bool verbose) + { + Logger.Verbose = verbose; + + var entries = HistoryManager.LoadEntries(); + if (entries.Count == 0) + { + AnsiConsole.MarkupLine("[yellow]No history found. Cannot generate statistics.[/]"); + return; + } + + var totalCount = entries.Count; + var totalDuration = TimeSpan.FromMilliseconds(entries.Sum(e => e.DurationMs)); + var avgDuration = TimeSpan.FromMilliseconds(entries.Average(e => e.DurationMs)); + + var mostActiveDay = entries + .GroupBy(e => e.Timestamp.Date) + .OrderByDescending(g => g.Count()) + .FirstOrDefault(); + + var topWords = entries + .SelectMany(e => e.RefinedText.Split(new[] { ' ', '.', ',', '!', '?' }, StringSplitOptions.RemoveEmptyEntries)) + .Where(w => w.Length > 3) // Exclude short common words + .GroupBy(w => w.ToLowerInvariant()) + .OrderByDescending(g => g.Count()) + .Take(5) + .Select(g => g.Key) + .ToList(); + + AnsiConsole.MarkupLine("[bold blue]Toak Usage Statistics[/]"); + AnsiConsole.MarkupLine($"[dim]Total recordings:[/] {totalCount}"); + AnsiConsole.MarkupLine($"[dim]Total duration:[/] {totalDuration.TotalMinutes:F1}m"); + AnsiConsole.MarkupLine($"[dim]Average processing latency:[/] {avgDuration.TotalSeconds:F2}s"); + + if (mostActiveDay != null) + { + AnsiConsole.MarkupLine($"[dim]Most active day:[/] {mostActiveDay.Key:yyyy-MM-dd} ({mostActiveDay.Count()} recordings)"); + } + + if (topWords.Count > 0) + { + AnsiConsole.MarkupLine($"[dim]Top spoken words (>3 chars):[/] {string.Join(", ", topWords)}"); + } + } +} diff --git a/Core/DaemonService.cs b/Core/DaemonService.cs index 5a1bd38..2020675 100644 --- a/Core/DaemonService.cs +++ b/Core/DaemonService.cs @@ -169,6 +169,7 @@ public static class DaemonService { detectedSkill!.Execute(finalText); stopWatch.Stop(); + HistoryManager.SaveEntry(transcript, finalText, detectedSkill.Name, stopWatch.ElapsedMilliseconds); Notifications.Notify("Toak", $"Skill executed in {stopWatch.ElapsedMilliseconds}ms"); } } @@ -194,11 +195,13 @@ public static class DaemonService ClipboardManager.Copy(fullText); Notifications.Notify("Toak", $"Copied to clipboard in {stopWatch.ElapsedMilliseconds}ms"); } + HistoryManager.SaveEntry(transcript, fullText, detectedSkill?.Name, stopWatch.ElapsedMilliseconds); } else { - await TextInjector.InjectStreamAsync(tokenStream, config.TypingBackend); + string fullText = await TextInjector.InjectStreamAsync(tokenStream, config.TypingBackend); stopWatch.Stop(); + HistoryManager.SaveEntry(transcript, fullText, detectedSkill?.Name, stopWatch.ElapsedMilliseconds); Notifications.Notify("Toak", $"Done in {stopWatch.ElapsedMilliseconds}ms"); } } diff --git a/Core/HistoryEntry.cs b/Core/HistoryEntry.cs new file mode 100644 index 0000000..799379f --- /dev/null +++ b/Core/HistoryEntry.cs @@ -0,0 +1,12 @@ +using System; + +namespace Toak.Core; + +public class HistoryEntry +{ + public DateTime Timestamp { get; set; } + public string RawTranscript { get; set; } = string.Empty; + public string RefinedText { get; set; } = string.Empty; + public string? SkillName { get; set; } + public long DurationMs { get; set; } +} diff --git a/Core/HistoryManager.cs b/Core/HistoryManager.cs new file mode 100644 index 0000000..87dc341 --- /dev/null +++ b/Core/HistoryManager.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using Toak.Serialization; + +namespace Toak.Core; + +public static class HistoryManager +{ + private static readonly string HistoryDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "toak"); + private static readonly string HistoryFile = Path.Combine(HistoryDir, "history.jsonl"); + + public static void SaveEntry(string rawTranscript, string refinedText, string? skillName, long durationMs) + { + try + { + if (!Directory.Exists(HistoryDir)) + { + Directory.CreateDirectory(HistoryDir); + } + + var entry = new HistoryEntry + { + Timestamp = DateTime.UtcNow, + RawTranscript = rawTranscript, + RefinedText = refinedText, + SkillName = skillName, + DurationMs = durationMs + }; + + var json = JsonSerializer.Serialize(entry, AppJsonSerializerContext.Default.HistoryEntry); + + // Thread-safe append + lock (HistoryFile) + { + File.AppendAllLines(HistoryFile, new[] { json }); + } + } + catch (Exception ex) + { + Logger.LogDebug($"Failed to save history: {ex.Message}"); + } + } + + public static List LoadEntries() + { + var entries = new List(); + if (!File.Exists(HistoryFile)) return entries; + + try + { + string[] lines; + lock (HistoryFile) + { + lines = File.ReadAllLines(HistoryFile); + } + + foreach (var line in lines) + { + if (string.IsNullOrWhiteSpace(line)) continue; + var entry = JsonSerializer.Deserialize(line, AppJsonSerializerContext.Default.HistoryEntry); + if (entry != null) + { + entries.Add(entry); + } + } + } + catch (Exception ex) + { + Logger.LogDebug($"Failed to load history: {ex.Message}"); + } + + return entries; + } + + public static void Shred() + { + if (File.Exists(HistoryFile)) + { + try + { + lock (HistoryFile) + { + // Securely delete + var len = new FileInfo(HistoryFile).Length; + using (var fs = new FileStream(HistoryFile, FileMode.Open, FileAccess.Write)) + { + var blank = new byte[len]; + fs.Write(blank, 0, blank.Length); + } + File.Delete(HistoryFile); + } + } + catch (Exception ex) + { + Logger.LogDebug($"Failed to shred history: {ex.Message}"); + } + } + } +} diff --git a/IO/TextInjector.cs b/IO/TextInjector.cs index 33678c7..32144a3 100644 --- a/IO/TextInjector.cs +++ b/IO/TextInjector.cs @@ -46,8 +46,9 @@ public static class TextInjector } } - public static async Task InjectStreamAsync(IAsyncEnumerable tokenStream, string backend) + public static async Task InjectStreamAsync(IAsyncEnumerable tokenStream, string backend) { + string fullText = string.Empty; try { ProcessStartInfo pInfo; @@ -77,13 +78,14 @@ public static class TextInjector } using var process = Process.Start(pInfo); - if (process == null) return; + if (process == null) return string.Empty; Logger.LogDebug("Started stream injection process, waiting for tokens..."); await foreach (var token in tokenStream) { Logger.LogDebug($"Injecting token: '{token}'"); + fullText += token; await process.StandardInput.WriteAsync(token); await process.StandardInput.FlushAsync(); } @@ -97,5 +99,6 @@ public static class TextInjector Console.WriteLine($"[TextInjector] Error injecting text stream: {ex.Message}"); Notifications.Notify("Injection Error", "Could not type text stream into window."); } + return fullText; } } diff --git a/Program.cs b/Program.cs index 5036cb9..02206df 100644 --- a/Program.cs +++ b/Program.cs @@ -58,6 +58,24 @@ public class Program configCmd.SetHandler(ConfigUpdaterCommand.ExecuteAsync, keyArg, valArg, verboseOption); rootCommand.AddCommand(configCmd); + // Stats Command + var statsCmd = new Command("stats", "Display usage statistics and analytics"); + statsCmd.SetHandler(StatsCommand.ExecuteAsync, verboseOption); + rootCommand.AddCommand(statsCmd); + + // History Command + var historyCmd = new Command("history", "Display recent transcriptions with timestamps"); + var numArg = new Option(new[] { "-n", "--num" }, () => 10, "Number of recent entries to show"); + var grepArg = new Option("--grep", "Search through transcription history"); + var exportArg = new Option("--export", "Export transcription history to a Markdown file"); + var shredArg = new Option("--shred", "Securely delete transcription history"); + historyCmd.AddOption(numArg); + historyCmd.AddOption(grepArg); + historyCmd.AddOption(exportArg); + historyCmd.AddOption(shredArg); + historyCmd.SetHandler(HistoryCommand.ExecuteAsync, numArg, grepArg, exportArg, shredArg, verboseOption); + rootCommand.AddCommand(historyCmd); + // Skill Command rootCommand.AddCommand(SkillCommand.CreateCommand(verboseOption)); diff --git a/README.md b/README.md index 3a9d4bc..66216cf 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,8 @@ To remove Toak from your system, simply run: - **`toak show`**: Displays your current configuration in a clean table. - **`toak config `**: Quickly update a specific setting (e.g., `toak config whisper whisper-large-v3-turbo`). - **`toak skill`**: Manage dynamic JSON skills via `list`, `add`, or `remove` subcommands. +- **`toak history`**: Display your recent dictation history (`-n `, `--grep `, `--export `, `--shred`). +- **`toak stats`**: Display usage statistics and analytics like most active day and top words. ### Flags diff --git a/Serialization/AppJsonSerializerContext.cs b/Serialization/AppJsonSerializerContext.cs index 9709b18..b828972 100644 --- a/Serialization/AppJsonSerializerContext.cs +++ b/Serialization/AppJsonSerializerContext.cs @@ -19,6 +19,7 @@ namespace Toak.Serialization; [JsonSerializable(typeof(LlamaStreamDelta))] [JsonSerializable(typeof(LlamaStreamChoice[]))] [JsonSerializable(typeof(Toak.Core.Skills.SkillDefinition))] +[JsonSerializable(typeof(Toak.Core.HistoryEntry))] internal partial class AppJsonSerializerContext : JsonSerializerContext { } diff --git a/_toak b/_toak index 862b362..5ce8a10 100644 --- a/_toak +++ b/_toak @@ -20,6 +20,8 @@ _toak() { 'show:Show current configuration' 'config:Update a specific configuration setting' 'skill:Manage dynamic skills (list, add, remove)' + 'history:Display recent transcriptions with timestamps' + 'stats:Display usage statistics and analytics' ) _arguments -C \ @@ -56,6 +58,13 @@ _toak() { ) _describe -t commands 'skill command' skill_cmds ;; + history) + _arguments \ + '(-n --num)'{-n,--num}'[Number of recent entries to show]:count:(5 10 20 50)' \ + '--grep[Search through transcription history]:pattern:' \ + '--export[Export transcription history to a Markdown file]:file:_files' \ + '--shred[Securely delete transcription history]' + ;; *) _message "no more arguments" ;; diff --git a/docs/HISTORY_AND_STATS.md b/docs/HISTORY_AND_STATS.md new file mode 100644 index 0000000..774b221 --- /dev/null +++ b/docs/HISTORY_AND_STATS.md @@ -0,0 +1,43 @@ +# History and Stats Implementation Plan + +This document outlines the design and implementation of the `history` and `stats` features in Toak. + +## Data Storage +All transcriptions will be stored in a JSON Lines (`.jsonl`) file located at `~/.local/share/toak/history.jsonl`. +Since Toak uses Native AOT and JSON serialization needs source generation, we'll keep the model simple. + +**Entry Model:** +```json +{ + "Timestamp": "2025-01-15T09:23:00Z", + "RawTranscript": "hello world", + "RefinedText": "Hello world.", + "SkillName": "Professional", // null if default type/script + "DurationMs": 1500 // time taken for STT + LLM +} +``` + +## `toak history` Command +Provides access to past dictations. + +- `toak history` - Shows the last 10 entries. +- `toak history -n ` - Shows the last `` entries. +- `toak history --grep ` - Filters the history entries matching the given keyword in the RefinedText (case-insensitive). +- `toak history --export ` - Writes the output as a Markdown file. +- `toak history --shred` - Deletes the `history.jsonl` file entirely. + +## `toak stats` Command +Reads the `history.jsonl` file and outputs usage analytics using `Spectre.Console`. + +**Metrics:** +- Total recording count +- Total processing duration (sum of `DurationMs`) +- Average processing duration +- Most active day +- Most frequently used skill (if any) + +## Architecture Changes +1. **`HistoryManager.cs`**: Handles thread-safe appending `HistoryEntry` to the `.jsonl` file, reading, and clearing. +2. **`DaemonService.cs`**: Calls `HistoryManager.SaveEntry` during the `ProcessStopRecordingAsync` method after text is finalized. +3. **`HistoryCommand.cs` & `StatsCommand.cs`**: CLI command definitions. +4. **`AppJsonSerializerContext.cs`**: Needs `[JsonSerializable(typeof(HistoryEntry))]`. diff --git a/docs/STRUCTURE.md b/docs/STRUCTURE.md index e091039..3edef7c 100644 --- a/docs/STRUCTURE.md +++ b/docs/STRUCTURE.md @@ -23,13 +23,17 @@ Toak/ │ ├── ConfigUpdaterCommand.cs # Direct configuration modifications │ ├── ShowCommand.cs # Display current configuration │ ├── SkillCommand.cs # CLI controller for discovering and adding Dynamic JSON Skills -│ └── LatencyTestCommand.cs # Benchmark tool for API calls +│ ├── LatencyTestCommand.cs # Benchmark tool for API calls +│ ├── HistoryCommand.cs # CLI interface to query, export, or shred past transcripts +│ └── StatsCommand.cs # CLI interface to calculate analytics from history ├── Configuration/ │ ├── ConfigManager.cs # Loads and saves JSON configuration from the user's home folder │ └── ToakConfig.cs # Data model for user preferences ├── Core/ │ ├── DaemonService.cs # The background daemon maintaining the socket server and handling states │ ├── Logger.cs # Logging utility (verbose logging) +│ ├── HistoryManager.cs # Manages appending and reading the local history.jsonl +│ ├── HistoryEntry.cs # The data model for transcription history │ ├── PromptBuilder.cs # Constructs the system prompts for the LLM based on user settings │ ├── StateTracker.cs # Tracks the current application state (e.g. is recording active?) │ └── Skills/ # Data-driven JSON skill integrations