1
0

feat: Implement history tracking with new CLI commands for viewing past transcripts and usage statistics.

This commit is contained in:
2026-02-28 14:06:58 +01:00
parent eadbd8d46d
commit a08838fbc4
12 changed files with 349 additions and 4 deletions

View File

@@ -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);
}
}

56
Commands/StatsCommand.cs Normal file
View File

@@ -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)}");
}
}
}

View File

@@ -169,6 +169,7 @@ public static class DaemonService
{ {
detectedSkill!.Execute(finalText); detectedSkill!.Execute(finalText);
stopWatch.Stop(); stopWatch.Stop();
HistoryManager.SaveEntry(transcript, finalText, detectedSkill.Name, stopWatch.ElapsedMilliseconds);
Notifications.Notify("Toak", $"Skill executed in {stopWatch.ElapsedMilliseconds}ms"); Notifications.Notify("Toak", $"Skill executed in {stopWatch.ElapsedMilliseconds}ms");
} }
} }
@@ -194,11 +195,13 @@ public static class DaemonService
ClipboardManager.Copy(fullText); ClipboardManager.Copy(fullText);
Notifications.Notify("Toak", $"Copied to clipboard in {stopWatch.ElapsedMilliseconds}ms"); Notifications.Notify("Toak", $"Copied to clipboard in {stopWatch.ElapsedMilliseconds}ms");
} }
HistoryManager.SaveEntry(transcript, fullText, detectedSkill?.Name, stopWatch.ElapsedMilliseconds);
} }
else else
{ {
await TextInjector.InjectStreamAsync(tokenStream, config.TypingBackend); string fullText = await TextInjector.InjectStreamAsync(tokenStream, config.TypingBackend);
stopWatch.Stop(); stopWatch.Stop();
HistoryManager.SaveEntry(transcript, fullText, detectedSkill?.Name, stopWatch.ElapsedMilliseconds);
Notifications.Notify("Toak", $"Done in {stopWatch.ElapsedMilliseconds}ms"); Notifications.Notify("Toak", $"Done in {stopWatch.ElapsedMilliseconds}ms");
} }
} }

12
Core/HistoryEntry.cs Normal file
View File

@@ -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; }
}

102
Core/HistoryManager.cs Normal file
View File

@@ -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<HistoryEntry> LoadEntries()
{
var entries = new List<HistoryEntry>();
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}");
}
}
}
}

View File

@@ -46,8 +46,9 @@ public static class TextInjector
} }
} }
public static async Task InjectStreamAsync(IAsyncEnumerable<string> tokenStream, string backend) public static async Task<string> InjectStreamAsync(IAsyncEnumerable<string> tokenStream, string backend)
{ {
string fullText = string.Empty;
try try
{ {
ProcessStartInfo pInfo; ProcessStartInfo pInfo;
@@ -77,13 +78,14 @@ public static class TextInjector
} }
using var process = Process.Start(pInfo); 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..."); Logger.LogDebug("Started stream injection process, waiting for tokens...");
await foreach (var token in tokenStream) await foreach (var token in tokenStream)
{ {
Logger.LogDebug($"Injecting token: '{token}'"); Logger.LogDebug($"Injecting token: '{token}'");
fullText += token;
await process.StandardInput.WriteAsync(token); await process.StandardInput.WriteAsync(token);
await process.StandardInput.FlushAsync(); await process.StandardInput.FlushAsync();
} }
@@ -97,5 +99,6 @@ public static class TextInjector
Console.WriteLine($"[TextInjector] Error injecting text stream: {ex.Message}"); Console.WriteLine($"[TextInjector] Error injecting text stream: {ex.Message}");
Notifications.Notify("Injection Error", "Could not type text stream into window."); Notifications.Notify("Injection Error", "Could not type text stream into window.");
} }
return fullText;
} }
} }

View File

@@ -58,6 +58,24 @@ public class Program
configCmd.SetHandler(ConfigUpdaterCommand.ExecuteAsync, keyArg, valArg, verboseOption); configCmd.SetHandler(ConfigUpdaterCommand.ExecuteAsync, keyArg, valArg, verboseOption);
rootCommand.AddCommand(configCmd); 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<int>(new[] { "-n", "--num" }, () => 10, "Number of recent entries to show");
var grepArg = new Option<string>("--grep", "Search through transcription history");
var exportArg = new Option<string>("--export", "Export transcription history to a Markdown file");
var shredArg = new Option<bool>("--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 // Skill Command
rootCommand.AddCommand(SkillCommand.CreateCommand(verboseOption)); rootCommand.AddCommand(SkillCommand.CreateCommand(verboseOption));

View File

@@ -60,6 +60,8 @@ To remove Toak from your system, simply run:
- **`toak show`**: Displays your current configuration in a clean table. - **`toak show`**: Displays your current configuration in a clean table.
- **`toak config <key> <value>`**: Quickly update a specific setting (e.g., `toak config whisper whisper-large-v3-turbo`). - **`toak config <key> <value>`**: 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 skill`**: Manage dynamic JSON skills via `list`, `add`, or `remove` subcommands.
- **`toak history`**: Display your recent dictation history (`-n <count>`, `--grep <pattern>`, `--export <file>`, `--shred`).
- **`toak stats`**: Display usage statistics and analytics like most active day and top words.
### Flags ### Flags

View File

@@ -19,6 +19,7 @@ namespace Toak.Serialization;
[JsonSerializable(typeof(LlamaStreamDelta))] [JsonSerializable(typeof(LlamaStreamDelta))]
[JsonSerializable(typeof(LlamaStreamChoice[]))] [JsonSerializable(typeof(LlamaStreamChoice[]))]
[JsonSerializable(typeof(Toak.Core.Skills.SkillDefinition))] [JsonSerializable(typeof(Toak.Core.Skills.SkillDefinition))]
[JsonSerializable(typeof(Toak.Core.HistoryEntry))]
internal partial class AppJsonSerializerContext : JsonSerializerContext internal partial class AppJsonSerializerContext : JsonSerializerContext
{ {
} }

9
_toak
View File

@@ -20,6 +20,8 @@ _toak() {
'show:Show current configuration' 'show:Show current configuration'
'config:Update a specific configuration setting' 'config:Update a specific configuration setting'
'skill:Manage dynamic skills (list, add, remove)' 'skill:Manage dynamic skills (list, add, remove)'
'history:Display recent transcriptions with timestamps'
'stats:Display usage statistics and analytics'
) )
_arguments -C \ _arguments -C \
@@ -56,6 +58,13 @@ _toak() {
) )
_describe -t commands 'skill command' skill_cmds _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" _message "no more arguments"
;; ;;

43
docs/HISTORY_AND_STATS.md Normal file
View File

@@ -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 <count>` - Shows the last `<count>` entries.
- `toak history --grep <pattern>` - Filters the history entries matching the given keyword in the RefinedText (case-insensitive).
- `toak history --export <file>` - 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))]`.

View File

@@ -23,13 +23,17 @@ Toak/
│ ├── ConfigUpdaterCommand.cs # Direct configuration modifications │ ├── ConfigUpdaterCommand.cs # Direct configuration modifications
│ ├── ShowCommand.cs # Display current configuration │ ├── ShowCommand.cs # Display current configuration
│ ├── SkillCommand.cs # CLI controller for discovering and adding Dynamic JSON Skills │ ├── 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/ ├── Configuration/
│ ├── ConfigManager.cs # Loads and saves JSON configuration from the user's home folder │ ├── ConfigManager.cs # Loads and saves JSON configuration from the user's home folder
│ └── ToakConfig.cs # Data model for user preferences │ └── ToakConfig.cs # Data model for user preferences
├── Core/ ├── Core/
│ ├── DaemonService.cs # The background daemon maintaining the socket server and handling states │ ├── DaemonService.cs # The background daemon maintaining the socket server and handling states
│ ├── Logger.cs # Logging utility (verbose logging) │ ├── 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 │ ├── 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?) │ ├── StateTracker.cs # Tracks the current application state (e.g. is recording active?)
│ └── Skills/ # Data-driven JSON skill integrations │ └── Skills/ # Data-driven JSON skill integrations