feat: Implement history tracking with new CLI commands for viewing past transcripts and usage statistics.
This commit is contained in:
92
Commands/HistoryCommand.cs
Normal file
92
Commands/HistoryCommand.cs
Normal 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
56
Commands/StatsCommand.cs
Normal 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)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
12
Core/HistoryEntry.cs
Normal file
12
Core/HistoryEntry.cs
Normal 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
102
Core/HistoryManager.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
18
Program.cs
18
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<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
|
||||
rootCommand.AddCommand(SkillCommand.CreateCommand(verboseOption));
|
||||
|
||||
|
||||
@@ -60,6 +60,8 @@ To remove Toak from your system, simply run:
|
||||
- **`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 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
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
9
_toak
9
_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"
|
||||
;;
|
||||
|
||||
43
docs/HISTORY_AND_STATS.md
Normal file
43
docs/HISTORY_AND_STATS.md
Normal 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))]`.
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user