diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5cc90f37..85ccbb1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,10 +59,11 @@ jobs: - name: Run cycodt tests run: | - export PATH=$PATH:$(pwd)/src/cycod/bin/Release/net9.0:$(pwd)/src/cycodt/bin/Release/net9.0:$(pwd)/src/cycodmd/bin/Release/net9.0:$(pwd)/src/cycodgr/bin/Release/net9.0 + export PATH=$PATH:$(pwd)/src/cycod/bin/Release/net9.0:$(pwd)/src/cycodt/bin/Release/net9.0:$(pwd)/src/cycodmd/bin/Release/net9.0:$(pwd)/src/cycodgr/bin/Release/net9.0:$(pwd)/src/cycodj/bin/Release/net9.0 which cycod which cycodmd which cycodgr + which cycodj which cycodt cycodt run --log ./TestResults/test-results-cycodt.log --output-file ./TestResults/test-results-cycodt.trx @@ -129,3 +130,14 @@ jobs: src/cycodgr/bin/Release/net9.0/win-x64/publish/ src/cycodgr/bin/Release/net9.0/linux-x64/publish/ src/cycodgr/bin/Release/net9.0/osx-x64/publish/ + + - name: Upload cycodj build artifacts + uses: actions/upload-artifact@v4 + with: + name: cycodj-build + path: | + src/cycodj/bin/Release/net9.0/ + src/cycodj/bin/Release/net9.0/win-x64/publish/ + src/cycodj/bin/Release/net9.0/linux-x64/publish/ + src/cycodj/bin/Release/net9.0/osx-x64/publish/ + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c2744e2d..41a9823f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -72,10 +72,11 @@ jobs: - name: Run cycodt tests run: | - export PATH=$PATH:$(pwd)/src/cycod/bin/Release/net9.0:$(pwd)/src/cycodt/bin/Release/net9.0:$(pwd)/src/cycodmd/bin/Release/net9.0:$(pwd)/src/cycodgr/bin/Release/net9.0 + export PATH=$PATH:$(pwd)/src/cycod/bin/Release/net9.0:$(pwd)/src/cycodt/bin/Release/net9.0:$(pwd)/src/cycodmd/bin/Release/net9.0:$(pwd)/src/cycodgr/bin/Release/net9.0:$(pwd)/src/cycodj/bin/Release/net9.0 which cycod which cycodmd which cycodgr + which cycodj which cycodt cycodt run --output-file ./TestResults/test-results-cycodt.trx @@ -109,7 +110,7 @@ jobs: - name: Upload NuGet packages and checksums uses: actions/upload-artifact@v4 with: - name: cycod-cycodt-cycodmd-cycodgr-nuget-packages + name: cycod-cycodt-cycodmd-cycodgr-cycodj-nuget-packages path: nuget-packages/* - name: Build self-contained executables @@ -160,6 +161,9 @@ jobs: - cycodgr-win-x64-${{ env.VERSION }}.zip - cycodgr-linux-x64-${{ env.VERSION }}.zip - cycodgr-osx-x64-${{ env.VERSION }}.zip + - cycodj-win-x64-${{ env.VERSION }}.zip + - cycodj-linux-x64-${{ env.VERSION }}.zip + - cycodj-osx-x64-${{ env.VERSION }}.zip - Platform packages (all tools): - cycodev-tools-win-x64-${{ env.VERSION }}.zip diff --git a/.gitignore b/.gitignore index 06dddcde..256a6fa9 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,10 @@ src/cycodmd/bin/ src/cycodmd/obj/ src/cycodt/obj/ src/cycodt/bin/ +src/cycodgr/bin/ +src/cycodgr/obj/ +src/cycodj/bin/ +src/cycodj/obj/ src/mcp/**/obj/ src/mcp/**/bin/ @@ -26,10 +30,3 @@ tests/cycodt-yaml/inception-layer-1/test-results-simple.trx tests/cycodt-yaml/inception-layer-1/test-results-simple?.trx *.log .vscode/settings.json - -obj/ -# Test cloned repos -temp/external/ -temp/test-repos/ -src/cycodgr/bin/ -src/cycodgr/obj/ diff --git a/cycod.sln b/cycod.sln index 10b2f368..02b78425 100644 --- a/cycod.sln +++ b/cycod.sln @@ -29,6 +29,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cycodgr", "src\cycodgr\cycodgr.csproj", "{7C627587-758D-45D7-A130-CE637A9081CB}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cycodj", "src\cycodj\cycodj.csproj", "{679FA56A-BCC9-4223-87F1-7F25373947AB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -171,6 +173,18 @@ Global {7C627587-758D-45D7-A130-CE637A9081CB}.Release|x64.Build.0 = Release|Any CPU {7C627587-758D-45D7-A130-CE637A9081CB}.Release|x86.ActiveCfg = Release|Any CPU {7C627587-758D-45D7-A130-CE637A9081CB}.Release|x86.Build.0 = Release|Any CPU + {679FA56A-BCC9-4223-87F1-7F25373947AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {679FA56A-BCC9-4223-87F1-7F25373947AB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {679FA56A-BCC9-4223-87F1-7F25373947AB}.Debug|x64.ActiveCfg = Debug|Any CPU + {679FA56A-BCC9-4223-87F1-7F25373947AB}.Debug|x64.Build.0 = Debug|Any CPU + {679FA56A-BCC9-4223-87F1-7F25373947AB}.Debug|x86.ActiveCfg = Debug|Any CPU + {679FA56A-BCC9-4223-87F1-7F25373947AB}.Debug|x86.Build.0 = Debug|Any CPU + {679FA56A-BCC9-4223-87F1-7F25373947AB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {679FA56A-BCC9-4223-87F1-7F25373947AB}.Release|Any CPU.Build.0 = Release|Any CPU + {679FA56A-BCC9-4223-87F1-7F25373947AB}.Release|x64.ActiveCfg = Release|Any CPU + {679FA56A-BCC9-4223-87F1-7F25373947AB}.Release|x64.Build.0 = Release|Any CPU + {679FA56A-BCC9-4223-87F1-7F25373947AB}.Release|x86.ActiveCfg = Release|Any CPU + {679FA56A-BCC9-4223-87F1-7F25373947AB}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -182,6 +196,7 @@ Global {7DC30BDB-675F-4228-8494-3BCCFF91E879} = {691974F9-483A-4232-9746-F777D75C8958} {5283A75C-3392-4C80-B4DE-A8C62779ACFC} = {691974F9-483A-4232-9746-F777D75C8958} {7C627587-758D-45D7-A130-CE637A9081CB} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {679FA56A-BCC9-4223-87F1-7F25373947AB} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E4317ED2-53B7-4A13-8AC7-3FD18B517E96} diff --git a/docs/adding-new-cli-tool.md b/docs/adding-new-cli-tool.md new file mode 100644 index 00000000..d6699e57 --- /dev/null +++ b/docs/adding-new-cli-tool.md @@ -0,0 +1,402 @@ +# Adding a New CLI Tool to the Project + +## Overview + +This document describes the exact steps needed to add a new CLI tool to the cycod project ecosystem, based on how `cycodgr` was added. + +## Reference Commit + +The primary commit that added `cycodgr`: **14169d53** - "feat: Implement cycodgr - GitHub Repository and Code Search CLI (Phases A-E Complete)" + +## Required Changes + +### 1. Create Project Structure + +``` +src// +├── .csproj # Project file +├── Program.cs # Entry point +├── ProgramInfo.cs # Program info class +├── README.md # Tool-specific documentation +├── CommandLine/ # Command-line parsing +│ ├── Command.cs +│ └── CommandLineOptions.cs +├── CommandLineCommands/ # Command implementations +│ └── Command.cs +├── Helpers/ # Helper classes +│ └── Helpers.cs +├── Models/ # Data models (if needed) +│ ├── ModelA.cs +│ └── ModelB.cs +└── assets/ # Embedded resources + ├── help/ # Help text files + │ ├── usage.txt + │ ├── examples.txt + │ └── ... + └── prompts/ # AI prompts (if needed) + ├── system.md + └── user.md +``` + +### 2. Create Project File (`.csproj`) + +**Key elements:** + +```xml + + + + + + net9.0 + enable + enable + true + toolname + + Exe + + + win-x64;linux-x64;osx-x64 + + + ToolName + Rob Chambers + Tool description here + cli;relevant;tags;here + https://github.com/robch/cycod + MIT + README.md + + + true + toolname + false + + + + + + + + + + + + + help\%(RecursiveDir)%(Filename)%(Extension) + + + prompts\%(RecursiveDir)%(Filename)%(Extension) + + + + +``` + +### 3. Create Program.cs + +**Template structure:** + +```csharp +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using ToolNamespace.CommandLine; + +class Program +{ + static async Task Main(string[] args) + { + ToolNameProgramInfo _programInfo = new(); + + LoggingInitializer.InitializeMemoryLogger(); + LoggingInitializer.LogStartupDetails(args); + Logger.Info($"Starting {ProgramInfo.Name}, version {VersionInfo.GetVersion()}"); + + if (!ToolNameCommandLineOptions.Parse(args, out var commandLineOptions, out var ex)) + { + DisplayBanner(); + if (ex != null) + { + Logger.Error($"Command line error: {ex.Message}"); + DisplayException(ex); + HelpHelpers.DisplayUsage(ex.GetHelpTopic()); + return 2; + } + else + { + Logger.Warning("Displaying help due to command line parsing issue"); + HelpHelpers.DisplayUsage(commandLineOptions!.HelpTopic); + return 1; + } + } + + var debug = ConsoleHelpers.IsDebug() || commandLineOptions!.Debug; + var verbose = ConsoleHelpers.IsVerbose() || commandLineOptions!.Verbose; + var quiet = ConsoleHelpers.IsQuiet() || commandLineOptions!.Quiet; + ConsoleHelpers.Configure(debug, verbose, quiet); + + LoggingInitializer.InitializeLogging(commandLineOptions?.LogFile, debug); + + var helpCommand = commandLineOptions!.Commands.OfType().FirstOrDefault(); + if (helpCommand != null) + { + DisplayBanner(); + HelpHelpers.DisplayHelpTopic(commandLineOptions.HelpTopic, commandLineOptions.ExpandHelpTopics); + return 0; + } + + var versionCommand = commandLineOptions!.Commands.OfType().FirstOrDefault(); + if (versionCommand != null) + { + DisplayBanner(); + var version = await versionCommand.ExecuteAsync(false); + ConsoleHelpers.WriteLine(version.ToString()!); + return 0; + } + + foreach (var command in commandLineOptions.Commands) + { + // Handle your specific commands here + if (command is YourCommandNamespace.YourCommand yourCommand) + { + await HandleYourCommandAsync(yourCommand); + } + } + + return 0; + } + + private static async Task HandleYourCommandAsync(YourCommand command) + { + try + { + // Command implementation + } + catch (Exception ex) + { + ConsoleHelpers.WriteErrorLine($"Error: {ex.Message}"); + Logger.Error($"Command failed: {ex.Message}"); + Logger.Error(ex.StackTrace ?? string.Empty); + } + } + + private static void DisplayBanner() + { + ConsoleHelpers.WriteWithHighlight($"{ProgramInfo.Name} {VersionInfo.GetVersion()}"); + } + + private static void DisplayException(Exception ex) + { + ConsoleHelpers.WriteErrorLine(ex.Message); + if (ConsoleHelpers.IsDebug()) + { + ConsoleHelpers.WriteErrorLine(ex.StackTrace ?? string.Empty); + } + } +} +``` + +### 4. Create ProgramInfo Class + +```csharp +public class ToolNameProgramInfo : ProgramInfo +{ + public ToolNameProgramInfo() : base( + () => "toolname", + () => "Tool Description Here", + () => ".cycod", // Config directory + () => typeof(ToolNameProgramInfo).Assembly) + { + } +} +``` + +### 5. Add to Solution File (`cycod.sln`) + +Add these lines: + +```xml + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "toolname", "src\toolname\toolname.csproj", "{GUID-HERE}" +EndProject + + +{GUID-HERE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU +{GUID-HERE}.Debug|Any CPU.Build.0 = Debug|Any CPU +{GUID-HERE}.Release|Any CPU.ActiveCfg = Release|Any CPU +{GUID-HERE}.Release|Any CPU.Build.0 = Release|Any CPU + + + +{GUID-HERE} = {SOLUTION-FOLDER-GUID} +``` + +**To generate GUID:** Use Visual Studio or `uuidgen` / online GUID generator + +### 6. Update CI/CD Workflows + +#### `.github/workflows/ci.yml`: + +```yaml +# Add to the PATH export +export PATH=$PATH:$(pwd)/src/toolname/bin/Release/net9.0 + +# Add which check +which toolname + +# Add artifact upload +- name: Upload toolname build artifacts + uses: actions/upload-artifact@v4 + with: + name: toolname-build + path: | + src/toolname/bin/Release/net9.0/ + src/toolname/bin/Release/net9.0/win-x64/publish/ + src/toolname/bin/Release/net9.0/linux-x64/publish/ + src/toolname/bin/Release/net9.0/osx-x64/publish/ +``` + +#### `.github/workflows/release.yml`: + +```yaml +# Update PATH export (same as ci.yml) +export PATH=$PATH:$(pwd)/src/toolname/bin/Release/net9.0 + +# Add which check +which toolname + +# Update NuGet packages artifact name +name: cycod-cycodt-cycodmd-toolname-nuget-packages + +# Add to release body +- toolname-win-x64-${{ env.VERSION }}.zip +- toolname-linux-x64-${{ env.VERSION }}.zip +- toolname-osx-x64-${{ env.VERSION }}.zip +``` + +### 7. Update Build Scripts + +#### `scripts/_functions.sh`: + +Update these arrays to include `"toolname"`: + +```bash +# In cycod_build_dotnet() +local PROJECTS=("src/common/common.csproj" "src/cycod/cycod.csproj" "src/cycodt/cycodt.csproj" "src/cycodmd/cycodmd.csproj" "src/toolname/toolname.csproj") + +# In cycod_pack_dotnet() +local TOOLS=("cycod" "cycodt" "cycodmd" "toolname") + +# In the install-local.sh script generation +TOOLS=("cycod" "cycodt" "cycodmd" "toolname") + +# In cycod_publish_self_contained() +local TOOLS=("cycod" "cycodt" "cycodmd" "toolname") +``` + +### 8. Update `.gitignore` (if needed) + +Usually not needed as build artifacts are already ignored, but check if your tool creates any special files. + +## Common Code Patterns + +### CommandLine Parsing + +Follow patterns from `common/CommandLine/` and reference implementations in cycod/cycodmd/cycodgr. + +### Console Output + +Use `ConsoleHelpers` methods: +- `ConsoleHelpers.WriteLine()` - with color support +- `ConsoleHelpers.WriteErrorLine()` - for errors +- `ConsoleHelpers.WriteDebugLine()` - for debug output +- `ConsoleHelpers.WriteWarning()` - for warnings + +### Logging + +Use `Logger` class: +- `Logger.Info()` - informational messages +- `Logger.Warning()` - warnings +- `Logger.Error()` - errors +- `Logger.Debug()` - debug messages + +### File Operations + +Use `FileHelpers` from common: +- `FileHelpers.ReadAllText()` +- `FileHelpers.WriteAllText()` +- `FileHelpers.FileExists()` +- `FileHelpers.GetFileNameFromTemplate()` + +### Configuration + +Use `ConfigStore` for settings: +```csharp +var setting = ConfigStore.Instance.GetFromAnyScope("setting.key").AsString("default"); +``` + +## Testing Checklist + +After adding your new CLI: + +- [ ] Solution builds successfully: `dotnet build` +- [ ] Tool runs: `dotnet run --project src/toolname/toolname.csproj -- --help` +- [ ] Tool is in PATH during CI (check `which toolname` output) +- [ ] NuGet package can be created: `dotnet pack src/toolname/toolname.csproj` +- [ ] Self-contained builds work for all platforms +- [ ] CI workflow passes +- [ ] Release workflow includes the new tool +- [ ] Help documentation is accessible +- [ ] Existing tests still pass + +## Naming Conventions + +- **Project folder:** lowercase `toolname` +- **Executable:** lowercase `toolname` +- **NuGet PackageId:** PascalCase `ToolName` or creative name like `CycoDgr` +- **Namespace:** PascalCase `ToolName` or `ToolNamespace` +- **ProgramInfo class:** `ToolNameProgramInfo` + +## Documentation + +Create a `README.md` in the tool folder that includes: +- Purpose and goals +- Installation instructions +- Usage examples +- Command reference +- Common use cases + +## Common Pitfalls + +1. **Forgetting to add to solution file** - Tool won't build in CI +2. **Not updating PATH in CI workflows** - Integration tests will fail +3. **Missing from build scripts** - Won't be included in releases +4. **Incorrect PackageId casing** - NuGet package may conflict +5. **Not setting `PackAsTool=true`** - Won't install as global tool +6. **Missing ProjectReference to common** - Won't have access to shared helpers + +## Reference Tools to Study + +- **cycod** - Main tool, most complex, has chat/AI features +- **cycodt** - Test framework, good example of file processing +- **cycodmd** - Markdown tool, simpler, good for file operations +- **cycodgr** - GitHub search, external API integration, parallel processing + +## Summary + +To add a new CLI tool: + +1. Create project structure in `src/toolname/` +2. Create `.csproj` with proper NuGet/tool configuration +3. Create `Program.cs` and `ProgramInfo.cs` +4. Add to `cycod.sln` +5. Update CI/CD workflows (`.github/workflows/*.yml`) +6. Update build scripts (`scripts/_functions.sh`) +7. Test locally and in CI +8. Document in README.md + +Follow the patterns established in existing tools for consistency! diff --git a/scripts/_functions.sh b/scripts/_functions.sh index f9074f6b..d74e31e9 100644 --- a/scripts/_functions.sh +++ b/scripts/_functions.sh @@ -115,7 +115,7 @@ cycod_build_dotnet() { echo "Building cycod projects with Version=$VERSION, NumericVersion=$NUMERIC_VERSION" # List of projects to build - local PROJECTS=("src/common/common.csproj" "src/cycod/cycod.csproj" "src/cycodt/cycodt.csproj" "src/cycodmd/cycodmd.csproj" "src/cycodgr/cycodgr.csproj") + local PROJECTS=("src/common/common.csproj" "src/cycod/cycod.csproj" "src/cycodt/cycodt.csproj" "src/cycodmd/cycodmd.csproj" "src/cycodgr/cycodgr.csproj" "src/cycodj/cycodj.csproj") # First restore dependencies echo "Restoring dependencies..." @@ -162,7 +162,7 @@ cycod_pack_dotnet() { mkdir -p "$OUTPUT_DIR" # List of tools to pack - local TOOLS=("cycod" "cycodt" "cycodmd" "cycodgr") + local TOOLS=("cycod" "cycodt" "cycodmd" "cycodgr" "cycodj") # List of runtimes to publish for local RIDS=("win-x64" "linux-x64" "osx-x64") @@ -202,7 +202,7 @@ cycod_pack_dotnet() { set -euo pipefail VERSION="${VERSION}" -TOOLS=("cycod" "cycodt" "cycodmd" "cycodgr") +TOOLS=("cycod" "cycodt" "cycodmd" "cycodgr" "cycodj") # Resolve this script's folder, then the feed folder DIR="\$(cd "\$(dirname "\${BASH_SOURCE[0]}")" && pwd)" @@ -244,7 +244,7 @@ cycod_publish_self_contained() { mkdir -p "$OUTPUT_DIR" # List of tools to publish - local TOOLS=("cycod" "cycodt" "cycodmd" "cycodgr") + local TOOLS=("cycod" "cycodt" "cycodmd" "cycodgr" "cycodj") # List of runtimes to publish for local RIDS=("win-x64" "linux-x64" "osx-x64") diff --git a/src/cycodj/Analyzers/BranchDetector.cs b/src/cycodj/Analyzers/BranchDetector.cs new file mode 100644 index 00000000..67193d4a --- /dev/null +++ b/src/cycodj/Analyzers/BranchDetector.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CycoDj.Models; + +namespace CycoDj.Analyzers; + +/// +/// Detects conversation branching by analyzing shared tool_call_id sequences. +/// +public class BranchDetector +{ + /// + /// Analyzes conversations and sets ParentId for branched conversations. + /// + public static void DetectBranches(List conversations) + { + foreach (var conv in conversations) + { + // Skip if no tool call IDs (can't detect branches) + if (conv.ToolCallIds.Count == 0) + continue; + + // Find potential parents (conversations with same prefix) + var potentialParents = conversations + .Where(other => other.Id != conv.Id) + .Where(other => other.ToolCallIds.Count > 0) + .Where(other => HasCommonPrefix(conv, other)) + .OrderByDescending(other => GetCommonPrefixLength(conv, other)) + .ToList(); + + // Parent is the one that's an exact prefix (all its tool_call_ids match conv's beginning) + foreach (var parent in potentialParents) + { + if (IsExactPrefix(parent.ToolCallIds, conv.ToolCallIds)) + { + conv.ParentId = parent.Id; + parent.BranchIds.Add(conv.Id); + break; + } + } + } + } + + /// + /// Builds a conversation tree structure showing parent-child relationships. + /// + public static ConversationTree BuildTree(List conversations) + { + DetectBranches(conversations); + + var tree = new ConversationTree(); + + // Find root conversations (those without parents) + var roots = conversations.Where(c => c.ParentId == null).ToList(); + tree.Roots = roots; + + // Build lookup dictionary + tree.ConversationLookup = conversations.ToDictionary(c => c.Id, c => c); + + return tree; + } + + /// + /// Checks if two conversations share at least one common tool_call_id at the start. + /// + private static bool HasCommonPrefix(Conversation a, Conversation b) + { + if (a.ToolCallIds.Count == 0 || b.ToolCallIds.Count == 0) + return false; + + return a.ToolCallIds[0] == b.ToolCallIds[0]; + } + + /// + /// Returns the number of matching tool_call_ids at the beginning of both lists. + /// + private static int GetCommonPrefixLength(Conversation a, Conversation b) + { + var length = 0; + var minLength = Math.Min(a.ToolCallIds.Count, b.ToolCallIds.Count); + + for (var i = 0; i < minLength; i++) + { + if (a.ToolCallIds[i] == b.ToolCallIds[i]) + length++; + else + break; + } + + return length; + } + + /// + /// Checks if the prefix list is an exact prefix of the full list. + /// Prefix must be shorter and all elements must match. + /// + private static bool IsExactPrefix(List prefix, List full) + { + // Prefix must be shorter + if (prefix.Count >= full.Count) + return false; + + // All elements of prefix must match beginning of full + for (var i = 0; i < prefix.Count; i++) + { + if (prefix[i] != full[i]) + return false; + } + + return true; + } + + /// + /// Gets the branch depth of a conversation (how many ancestors it has). + /// + public static int GetBranchDepth(Conversation conv, Dictionary lookup) + { + var depth = 0; + var current = conv; + + while (current.ParentId != null && lookup.TryGetValue(current.ParentId, out var parent)) + { + depth++; + current = parent; + } + + return depth; + } + + /// + /// Gets all descendants of a conversation (children, grandchildren, etc.). + /// + public static List GetAllDescendants(Conversation conv, Dictionary lookup) + { + var descendants = new List(); + + foreach (var branchId in conv.BranchIds) + { + if (lookup.TryGetValue(branchId, out var branch)) + { + descendants.Add(branch); + descendants.AddRange(GetAllDescendants(branch, lookup)); + } + } + + return descendants; + } +} diff --git a/src/cycodj/Analyzers/ContentSummarizer.cs b/src/cycodj/Analyzers/ContentSummarizer.cs new file mode 100644 index 00000000..e4fee12e --- /dev/null +++ b/src/cycodj/Analyzers/ContentSummarizer.cs @@ -0,0 +1,522 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using CycoDj.Models; + +namespace CycoDj.Analyzers; + +/// +/// Analyzes and summarizes conversation content. +/// +public class ContentSummarizer +{ + /// + /// Extracts user message content as strings. + /// + public static List GetUserMessages(Conversation conv, bool excludeLarge = true, int maxLength = 10000) + { + if (conv == null) throw new ArgumentNullException(nameof(conv)); + if (conv.Messages == null) return new List(); + + var userMessages = conv.Messages + .Where(m => m?.Role == "user" && m.Content != null) + .Select(m => m.Content) + .ToList(); + + if (excludeLarge) + { + userMessages = userMessages + .Where(c => c.Length <= maxLength) + .ToList(); + } + + return userMessages; + } + + /// + /// Extracts user messages as ChatMessage objects (for accessing full message data). + /// + public static List GetUserMessagesRaw(Conversation conv, bool excludeLarge = true, int maxLength = 10000) + { + if (conv == null) throw new ArgumentNullException(nameof(conv)); + if (conv.Messages == null) return new List(); + + var userMessages = conv.Messages + .Where(m => m?.Role == "user" && m.Content != null) + .ToList(); + + if (excludeLarge) + { + userMessages = userMessages + .Where(m => m.Content.Length <= maxLength) + .ToList(); + } + + return userMessages; + } + + /// + /// Extracts assistant text responses as strings (not tool outputs). + /// + public static List GetAssistantResponses(Conversation conv, bool abbreviate = true, int maxLength = 500) + { + if (conv == null) throw new ArgumentNullException(nameof(conv)); + if (conv.Messages == null) return new List(); + + var assistantMessages = conv.Messages + .Where(m => m?.Role == "assistant" && !string.IsNullOrWhiteSpace(m.Content)) + .Select(m => m.Content) + .ToList(); + + if (abbreviate) + { + assistantMessages = assistantMessages + .Select(c => c.Length > maxLength ? c.Substring(0, maxLength) + "..." : c) + .ToList(); + } + + return assistantMessages; + } + + /// + /// Extracts assistant messages as ChatMessage objects (for accessing tool calls, etc). + /// + public static List GetAssistantMessagesRaw(Conversation conv, bool excludeWithToolCallsOnly = false) + { + if (conv == null) throw new ArgumentNullException(nameof(conv)); + if (conv.Messages == null) return new List(); + + var assistantMessages = conv.Messages + .Where(m => m?.Role == "assistant") + .ToList(); + + if (excludeWithToolCallsOnly) + { + // Exclude messages that only have tool calls and no text content + assistantMessages = assistantMessages + .Where(m => !string.IsNullOrWhiteSpace(m.Content)) + .ToList(); + } + + return assistantMessages; + } + + /// + /// Gets tool messages from a conversation. + /// + public static List GetToolMessages(Conversation conv) + { + if (conv == null) throw new ArgumentNullException(nameof(conv)); + if (conv.Messages == null) return new List(); + + return conv.Messages + .Where(m => m?.Role == "tool") + .ToList(); + } + + /// + /// Gets system messages from a conversation. + /// + public static List GetSystemMessages(Conversation conv) + { + if (conv == null) throw new ArgumentNullException(nameof(conv)); + if (conv.Messages == null) return new List(); + + return conv.Messages + .Where(m => m?.Role == "system") + .ToList(); + } + + /// + /// Filters messages by role. + /// + public static List FilterByRole(Conversation conv, string role) + { + if (conv == null) throw new ArgumentNullException(nameof(conv)); + if (string.IsNullOrEmpty(role)) throw new ArgumentNullException(nameof(role)); + if (conv.Messages == null) return new List(); + + return conv.Messages + .Where(m => m != null && m.Role.Equals(role, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + /// + /// Extracts tool call information from assistant messages. + /// + public static List<(string toolName, string toolCallId)> GetToolCallsInvoked(Conversation conv) + { + if (conv == null) throw new ArgumentNullException(nameof(conv)); + if (conv.Messages == null) return new List<(string, string)>(); + + var toolCalls = new List<(string, string)>(); + + foreach (var msg in conv.Messages.Where(m => m?.Role == "assistant")) + { + if (msg.ToolCalls != null && msg.ToolCalls.Count > 0) + { + foreach (var toolCall in msg.ToolCalls) + { + if (toolCall == null) continue; + var toolName = toolCall.Function?.Name ?? "unknown"; + toolCalls.Add((toolName, toolCall.Id ?? "unknown")); + } + } + } + + return toolCalls; + } + + /// + /// Gets a summary of actions taken (tool calls and their results). + /// + public static List GetActionSummary(Conversation conv, int maxToolOutputLength = 100) + { + if (conv == null) throw new ArgumentNullException(nameof(conv)); + if (conv.Messages == null) return new List(); + + var actions = new List(); + var toolCalls = GetToolCallsInvoked(conv); + + foreach (var (toolName, toolCallId) in toolCalls) + { + // Find the corresponding tool result + var toolResult = conv.Messages + .Where(m => m?.Role == "tool" && m.ToolCallId == toolCallId) + .FirstOrDefault(); + + if (toolResult != null && toolResult.Content != null) + { + var result = toolResult.Content.Length > maxToolOutputLength + ? toolResult.Content.Substring(0, maxToolOutputLength) + "..." + : toolResult.Content; + + // Extract just the first line or main result + var firstLine = result.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? result; + actions.Add($"{toolName}: {firstLine}"); + } + else + { + actions.Add($"{toolName}: (no result)"); + } + } + + return actions; + } + + /// + /// Checks if a tool message output is large and should be abbreviated. + /// + public static bool IsLargeToolOutput(ChatMessage msg, int threshold = 1000) + { + if (msg == null) return false; + if (msg.Role != "tool") return false; + if (msg.Content == null) return false; + + return msg.Content.Length > threshold; + } + + /// + /// Abbreviates large tool output for display. + /// + public static string AbbreviateToolOutput(ChatMessage msg, int maxLines = 5) + { + if (msg == null) return string.Empty; + if (msg.Content == null) return string.Empty; + if (msg.Role != "tool") return msg.Content; + + var lines = msg.Content.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); + + if (lines.Length <= maxLines) + return msg.Content; + + var abbreviated = new StringBuilder(); + for (int i = 0; i < maxLines && i < lines.Length; i++) + { + abbreviated.AppendLine(lines[i]); + } + abbreviated.AppendLine($"... ({lines.Length - maxLines} more lines)"); + + return abbreviated.ToString(); + } + + /// + /// Generates a brief summary of a conversation (as specified in architecture). + /// + public static string Summarize(Conversation conv, int maxLength = 200) + { + if (conv == null) throw new ArgumentNullException(nameof(conv)); + + var userMessages = GetUserMessages(conv, excludeLarge: true, maxLength: 500); + + if (userMessages.Count == 0) + { + return "(No user messages)"; + } + + // Take the first user message as the primary topic + var firstUserMsg = userMessages.First(); + + // Truncate if needed + if (firstUserMsg.Length > maxLength) + { + return firstUserMsg.Substring(0, maxLength) + "..."; + } + + return firstUserMsg; + } + + /// + /// Alias for Summarize - generates a brief summary of a conversation. + /// + public static string SummarizeConversation(Conversation conv, int maxLength = 200) + { + return Summarize(conv, maxLength); + } + + + /// + /// Generates a detailed summary with user actions and assistant responses. + /// + public static string SummarizeDetailed(Conversation conv, int maxUserMessages = 5, int maxAssistantResponses = 5, int maxActions = 10) + { + if (conv == null) throw new ArgumentNullException(nameof(conv)); + + var summary = new StringBuilder(); + + var userMessages = GetUserMessages(conv, excludeLarge: true, maxLength: 500); + var assistantResponses = GetAssistantResponses(conv, abbreviate: true, maxLength: 200); + var actions = GetActionSummary(conv, maxToolOutputLength: 100); + var toolMessages = GetToolMessages(conv); + + summary.AppendLine($"Conversation: {conv.GetDisplayTitle()}"); + summary.AppendLine($"Started: {conv.Timestamp:yyyy-MM-dd HH:mm:ss}"); + summary.AppendLine($"Messages: {(conv.Messages?.Count ?? 0)} total ({userMessages.Count} user, {assistantResponses.Count} assistant, {toolMessages.Count} tool)"); + + if (conv.BranchIds != null && conv.BranchIds.Count > 0) + { + summary.AppendLine($"Branches: {conv.BranchIds.Count}"); + } + + summary.AppendLine(); + + // User messages + if (userMessages.Count > 0) + { + summary.AppendLine("User:"); + var messagesToShow = userMessages.Take(maxUserMessages); + foreach (var msg in messagesToShow) + { + var content = msg.Length > 100 ? msg.Substring(0, 100) + "..." : msg; + summary.AppendLine($" > {content}"); + } + + if (userMessages.Count > maxUserMessages) + { + summary.AppendLine($" ... and {userMessages.Count - maxUserMessages} more"); + } + summary.AppendLine(); + } + + // Actions taken (tool calls with results) + if (actions.Count > 0) + { + summary.AppendLine("Actions:"); + var actionsToShow = actions.Take(maxActions); + foreach (var action in actionsToShow) + { + summary.AppendLine($" - {action}"); + } + + if (actions.Count > maxActions) + { + summary.AppendLine($" ... and {actions.Count - maxActions} more"); + } + summary.AppendLine(); + } + + // Assistant responses (text only) + if (assistantResponses.Count > 0) + { + summary.AppendLine("Assistant responses:"); + var responsesToShow = assistantResponses + .Where(r => !string.IsNullOrWhiteSpace(r) && r.Length > 10) // Filter very short responses + .Take(maxAssistantResponses); + + foreach (var response in responsesToShow) + { + var displayResponse = response.Length > 80 ? response.Substring(0, 80) + "..." : response; + summary.AppendLine($" - {displayResponse}"); + } + } + + return summary.ToString(); + } + + /// + /// Extracts a conversation title from metadata or content. + /// + public static string ExtractTitle(Conversation conv) + { + if (conv == null) throw new ArgumentNullException(nameof(conv)); + + // First, check metadata + if (!string.IsNullOrEmpty(conv.Metadata?.Title)) + { + return conv.Metadata.Title; + } + + // Fall back to first user message + var userMessages = GetUserMessages(conv, excludeLarge: true, maxLength: 500); + if (userMessages.Count > 0) + { + var firstMsg = userMessages.First(); + + // Take first line or first 50 characters + var firstLine = firstMsg.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? firstMsg; + + if (firstLine.Length > 50) + { + return firstLine.Substring(0, 50) + "..."; + } + + return firstLine; + } + + // Fall back to ID (ensure never null) + return conv.Id ?? "(Untitled)"; + } + + /// + /// Gets message count statistics for a conversation. + /// + public static (int user, int assistant, int tool, int system) GetMessageCounts(Conversation conv) + { + if (conv == null) throw new ArgumentNullException(nameof(conv)); + if (conv.Messages == null) return (0, 0, 0, 0); + + var userCount = conv.Messages.Count(m => m?.Role == "user"); + var assistantCount = conv.Messages.Count(m => m?.Role == "assistant"); + var toolCount = conv.Messages.Count(m => m?.Role == "tool"); + var systemCount = conv.Messages.Count(m => m?.Role == "system"); + + return (userCount, assistantCount, toolCount, systemCount); + } + + /// + /// Checks if a user message is likely piped content (heuristic). + /// + public static bool IsPossiblyPipedContent(ChatMessage msg, int lengthThreshold = 5000) + { + if (msg == null) return false; + if (msg.Role != "user") return false; + if (msg.Content == null) return false; + + // Heuristics: + // 1. Very long content + if (msg.Content.Length > lengthThreshold) + return true; + + // 2. Contains structured data patterns (JSON, XML, etc.) + var trimmed = msg.Content.TrimStart(); + if (trimmed.StartsWith("{") || trimmed.StartsWith("[")) + return true; + + if (trimmed.StartsWith("<") && msg.Content.Contains(" 3) + return true; + + return false; + } + + /// + /// Gets a count of tool calls by tool name. + /// + public static Dictionary GetToolCallStatistics(Conversation conv) + { + if (conv == null) throw new ArgumentNullException(nameof(conv)); + if (conv.Messages == null) return new Dictionary(); + + var stats = new Dictionary(); + + foreach (var msg in conv.Messages.Where(m => m?.Role == "assistant")) + { + if (msg.ToolCalls != null) + { + foreach (var toolCall in msg.ToolCalls) + { + if (toolCall == null) continue; + var toolName = toolCall.Function?.Name ?? "unknown"; + if (!stats.ContainsKey(toolName)) + { + stats[toolName] = 0; + } + stats[toolName]++; + } + } + } + + return stats; + } + + /// + /// Extracts file paths from file-related tool calls. + /// + public static List GetFilesModified(Conversation conv) + { + if (conv == null) throw new ArgumentNullException(nameof(conv)); + if (conv.Messages == null) return new List(); + + var files = new HashSet(); + + // File modification tools to look for + var fileModifyingTools = new[] { + "ReplaceOneInFile", "ReplaceAllInFiles", "CreateFile", + "Insert", "UndoEdit" + }; + + foreach (var msg in conv.Messages.Where(m => m?.Role == "assistant")) + { + if (msg.ToolCalls != null) + { + foreach (var toolCall in msg.ToolCalls) + { + if (toolCall == null) continue; + var toolName = toolCall.Function?.Name; + + if (toolName != null && fileModifyingTools.Contains(toolName)) + { + // Try to extract path from arguments + var args = toolCall.Function?.Arguments?.ToString(); + if (!string.IsNullOrEmpty(args)) + { + // Look for "path" field in JSON + var pathMatch = System.Text.RegularExpressions.Regex.Match( + args, + @"""path""\s*:\s*""([^""]+)"""); + + if (pathMatch.Success) + { + files.Add(pathMatch.Groups[1].Value); + } + } + } + } + } + } + + return files.OrderBy(f => f).ToList(); + } + + /// + /// Checks if a conversation involved file modifications. + /// + public static bool HasFileModifications(Conversation conv) + { + return GetFilesModified(conv).Count > 0; + } +} diff --git a/src/cycodj/CommandLine/CycoDjCommand.cs b/src/cycodj/CommandLine/CycoDjCommand.cs new file mode 100644 index 00000000..8e50642e --- /dev/null +++ b/src/cycodj/CommandLine/CycoDjCommand.cs @@ -0,0 +1,76 @@ +using System.Threading.Tasks; + +namespace CycoDj.CommandLine; + +public abstract class CycoDjCommand : Command +{ + // Common properties for instructions support + public string? Instructions { get; set; } + public bool UseBuiltInFunctions { get; set; } = false; + public string? SaveChatHistory { get; set; } + + // Common properties for time filtering + public DateTime? After { get; set; } + public DateTime? Before { get; set; } + + // Common properties for output + public string? SaveOutput { get; set; } + + public override bool IsEmpty() + { + return false; + } + + public override string GetCommandName() + { + return GetType().Name.Replace("Command", "").ToLowerInvariant(); + } + + public override async Task ExecuteAsync(bool interactive) + { + var result = await ExecuteAsync(); + return result; + } + + public abstract Task ExecuteAsync(); + + /// + /// Apply instructions to output if --instructions was provided + /// + protected string ApplyInstructionsIfProvided(string output) + { + if (string.IsNullOrEmpty(Instructions)) + { + return output; + } + + return AiInstructionProcessor.ApplyInstructions( + Instructions, + output, + UseBuiltInFunctions, + SaveChatHistory); + } + + /// + /// Save output to file if --save-output was provided + /// Returns true if output was saved (command should not print to console) + /// + protected bool SaveOutputIfRequested(string output) + { + if (string.IsNullOrEmpty(SaveOutput)) + { + return false; + } + + // Just use SaveOutput directly - FileHelpers.GetFileNameFromTemplate doesn't do template expansion like we thought + // For now, use the filename as-is + var fileName = SaveOutput; + + // Write output to file + File.WriteAllText(fileName, output); + + ConsoleHelpers.WriteLine($"Output saved to: {fileName}", ConsoleColor.Green); + + return true; + } +} diff --git a/src/cycodj/CommandLine/CycoDjCommandLineOptions.cs b/src/cycodj/CommandLine/CycoDjCommandLineOptions.cs new file mode 100644 index 00000000..2c69841b --- /dev/null +++ b/src/cycodj/CommandLine/CycoDjCommandLineOptions.cs @@ -0,0 +1,618 @@ +using System; +using CycoDj.CommandLineCommands; + +namespace CycoDj.CommandLine; + +public class CycoDjCommandLineOptions : CommandLineOptions +{ + public static bool Parse(string[] args, out CommandLineOptions? options, out CommandLineException? ex) + { + options = new CycoDjCommandLineOptions(); + return options.Parse(args, out ex); + } + + override protected Command? NewDefaultCommand() + { + // Default to list command if no command specified + return new ListCommand(); + } + + override protected string PeekCommandName(string[] args, int i) + { + var name = base.PeekCommandName(args, i); + + // For single-word commands, just return the command name + var firstWord = name.Split(' ')[0].ToLowerInvariant(); + if (firstWord == "list" || firstWord == "show" || + firstWord == "branches" || firstWord == "search" || + firstWord == "stats" || firstWord == "cleanup") + { + return firstWord; + } + + return name; + } + + + override protected Command? NewCommandFromName(string commandName) + { + var lowerCommandName = commandName.ToLowerInvariant(); + + if (lowerCommandName.StartsWith("list")) return new ListCommand(); + if (lowerCommandName.StartsWith("show")) return new ShowCommand(); + if (lowerCommandName.StartsWith("branches")) return new BranchesCommand(); + if (lowerCommandName.StartsWith("search")) return new SearchCommand(); + if (lowerCommandName.StartsWith("stats")) return new StatsCommand(); + if (lowerCommandName.StartsWith("cleanup")) return new CleanupCommand(); + + return base.NewCommandFromName(commandName); + } + + /// + /// Try to parse common instruction-related options for all cycodj commands + /// + private bool TryParseCommonInstructionOptions(CycoDjCommand command, string[] args, ref int i, string arg) + { + if (arg == "--instructions") + { + var instructions = i + 1 < args.Length ? args[++i] : null; + if (string.IsNullOrWhiteSpace(instructions)) + { + throw new CommandLineException($"Missing instructions value for {arg}"); + } + command.Instructions = instructions; + return true; + } + else if (arg == "--use-built-in-functions") + { + command.UseBuiltInFunctions = true; + return true; + } + else if (arg == "--save-chat-history") + { + var savePath = i + 1 < args.Length ? args[++i] : null; + if (string.IsNullOrWhiteSpace(savePath)) + { + throw new CommandLineException($"Missing path value for {arg}"); + } + command.SaveChatHistory = savePath; + return true; + } + + return false; + } + + override protected bool TryParseOtherCommandOptions(Command? command, string[] args, ref int i, string arg) + { + // Try common instruction options first for all cycodj commands + if (command is CycoDjCommand cycodjCommand && TryParseCommonInstructionOptions(cycodjCommand, args, ref i, arg)) + { + return true; + } + + if (command is ListCommand listCommand) + { + return TryParseListCommandOptions(listCommand, args, ref i, arg); + } + else if (command is ShowCommand showCommand) + { + return TryParseShowCommandOptions(showCommand, args, ref i, arg); + } + else if (command is BranchesCommand branchesCommand) + { + return TryParseBranchesCommandOptions(branchesCommand, args, ref i, arg); + } + else if (command is SearchCommand searchCommand) + { + return TryParseSearchCommandOptions(searchCommand, args, ref i, arg); + } + else if (command is StatsCommand statsCommand) + { + return TryParseStatsCommandOptions(statsCommand, args, ref i, arg); + } + else if (command is CleanupCommand cleanupCommand) + { + return TryParseCleanupCommandOptions(cleanupCommand, args, ref i, arg); + } + + return false; + } + + /// + /// Try to parse common display options (--messages, --stats, --branches) + /// Returns true if option was handled + /// + private bool TryParseDisplayOptions(CycoDjCommand command, string[] args, ref int i, string arg) + { + // --messages [N|all] + if (arg == "--messages") + { + // Check if next arg is a value (not another option) + if (i + 1 < args.Length && !args[i + 1].StartsWith("-")) + { + var value = args[++i]; + if (value.Equals("all", StringComparison.OrdinalIgnoreCase)) + { + // Set to a large number (all messages) + SetMessageCount(command, int.MaxValue); + } + else if (int.TryParse(value, out var count)) + { + SetMessageCount(command, count); + } + else + { + throw new CommandLineException($"Invalid value for --messages: {value}"); + } + } + else + { + // No value provided, set to null (use command default) + SetMessageCount(command, null); + } + return true; + } + + // --stats + else if (arg == "--stats") + { + SetShowStats(command, true); + return true; + } + + // --branches (for list/search commands) + else if (arg == "--branches") + { + SetShowBranches(command, true); + return true; + } + + // --save-output + else if (arg == "--save-output") + { + var outputFile = i + 1 < args.Length ? args[++i] : null; + if (string.IsNullOrWhiteSpace(outputFile)) + { + throw new CommandLineException($"Missing file path for --save-output"); + } + command.SaveOutput = outputFile; + return true; + } + + return false; + } + + private void SetMessageCount(CycoDjCommand command, int? value) + { + var prop = command.GetType().GetProperty("MessageCount"); + if (prop != null) + { + prop.SetValue(command, value); + } + } + + private void SetShowStats(CycoDjCommand command, bool value) + { + var prop = command.GetType().GetProperty("ShowStats"); + if (prop != null) + { + prop.SetValue(command, value); + } + } + + private void SetShowBranches(CycoDjCommand command, bool value) + { + var prop = command.GetType().GetProperty("ShowBranches"); + if (prop != null) + { + prop.SetValue(command, value); + } + } + + /// + /// Try to parse common time filtering options (--today, --yesterday, --last, --after, --before, --date-range) + /// Returns true if option was handled + /// + private bool TryParseTimeOptions(CycoDjCommand command, string[] args, ref int i, string arg) + { + // --today shortcut (calendar day) + if (arg == "--today") + { + command.After = DateTime.Today; + command.Before = DateTime.Now; + return true; + } + + // --yesterday shortcut (calendar day) + else if (arg == "--yesterday") + { + command.After = DateTime.Today.AddDays(-1); + command.Before = DateTime.Today.AddTicks(-1); + return true; + } + + // --after + else if (arg == "--after" || arg == "--time-after") + { + var timeSpec = i + 1 < args.Length ? args[++i] : null; + if (string.IsNullOrWhiteSpace(timeSpec)) + { + throw new CommandLineException($"Missing timespec value for {arg}"); + } + command.After = TimeSpecHelpers.ParseSingleTimeSpec(arg, timeSpec, isAfter: true); + return true; + } + + // --before + else if (arg == "--before" || arg == "--time-before") + { + var timeSpec = i + 1 < args.Length ? args[++i] : null; + if (string.IsNullOrWhiteSpace(timeSpec)) + { + throw new CommandLineException($"Missing timespec value for {arg}"); + } + command.Before = TimeSpecHelpers.ParseSingleTimeSpec(arg, timeSpec, isAfter: false); + return true; + } + + // --date-range or --time-range + else if (arg == "--date-range" || arg == "--time-range") + { + var timeSpec = i + 1 < args.Length ? args[++i] : null; + if (string.IsNullOrWhiteSpace(timeSpec)) + { + throw new CommandLineException($"Missing timespec range for {arg}"); + } + var (after, before) = TimeSpecHelpers.ParseTimeSpecRange(arg, timeSpec); + command.After = after; + command.Before = before; + return true; + } + + return false; + } + + private bool TryParseListCommandOptions(ListCommand command, string[] args, ref int i, string arg) + { + // Try common display options first + if (TryParseDisplayOptions(command, args, ref i, arg)) + { + return true; + } + + // Try common time options + if (TryParseTimeOptions(command, args, ref i, arg)) + { + return true; + } + + if (arg == "--date" || arg == "-d") + { + var date = i + 1 < args.Length ? args[++i] : null; + if (string.IsNullOrWhiteSpace(date)) + { + throw new CommandLineException($"Missing date value for {arg}"); + } + command.Date = date; + return true; + } + else if (arg == "--last") + { + var value = i + 1 < args.Length ? args[++i] : null; + if (string.IsNullOrWhiteSpace(value)) + { + throw new CommandLineException($"Missing value for {arg}"); + } + + ParseLastValue(command, arg, value); + return true; + } + + return false; + } + + private bool TryParseBranchesCommandOptions(BranchesCommand command, string[] args, ref int i, string arg) + { + // Try common display options first + if (TryParseDisplayOptions(command, args, ref i, arg)) + { + return true; + } + + // Try common time options + if (TryParseTimeOptions(command, args, ref i, arg)) + { + return true; + } + + if (arg == "--date" || arg == "-d") + { + var date = i + 1 < args.Length ? args[++i] : null; + if (string.IsNullOrWhiteSpace(date)) + { + throw new CommandLineException($"Missing date value for {arg}"); + } + command.Date = date; + return true; + } + else if (arg == "--last") + { + var value = i + 1 < args.Length ? args[++i] : null; + if (string.IsNullOrWhiteSpace(value)) + { + throw new CommandLineException($"Missing value for {arg}"); + } + + ParseLastValue(command, arg, value); + return true; + } + else if (arg == "--conversation" || arg == "-c") + { + var conv = i + 1 < args.Length ? args[++i] : null; + if (string.IsNullOrWhiteSpace(conv)) + { + throw new CommandLineException($"Missing conversation value for {arg}"); + } + command.Conversation = conv; + return true; + } + else if (arg == "--verbose" || arg == "-v") + { + command.Verbose = true; + return true; + } + + return false; + } + + private bool TryParseShowCommandOptions(ShowCommand command, string[] args, ref int i, string arg) + { + // First positional argument is the conversation ID + if (!arg.StartsWith("-") && string.IsNullOrEmpty(command.ConversationId)) + { + command.ConversationId = arg; + return true; + } + + // Try common display options first + if (TryParseDisplayOptions(command, args, ref i, arg)) + { + return true; + } + + if (arg == "--show-tool-calls") + { + command.ShowToolCalls = true; + return true; + } + else if (arg == "--show-tool-output") + { + command.ShowToolOutput = true; + return true; + } + else if (arg == "--max-content-length") + { + var length = i + 1 < args.Length ? args[++i] : null; + if (string.IsNullOrWhiteSpace(length) || !int.TryParse(length, out var n)) + { + throw new CommandLineException($"Missing or invalid length for {arg}"); + } + command.MaxContentLength = n; + return true; + } + + return false; + } + + private bool TryParseSearchCommandOptions(SearchCommand command, string[] args, ref int i, string arg) + { + // First positional argument is the search query + if (!arg.StartsWith("-") && string.IsNullOrEmpty(command.Query)) + { + command.Query = arg; + return true; + } + + // Try common display options first + if (TryParseDisplayOptions(command, args, ref i, arg)) + { + return true; + } + + // Try common time options + if (TryParseTimeOptions(command, args, ref i, arg)) + { + return true; + } + + if (arg == "--date" || arg == "-d") + { + var date = i + 1 < args.Length ? args[++i] : null; + if (string.IsNullOrWhiteSpace(date)) + { + throw new CommandLineException($"Missing date value for {arg}"); + } + command.Date = date; + return true; + } + else if (arg == "--last") + { + var value = i + 1 < args.Length ? args[++i] : null; + if (string.IsNullOrWhiteSpace(value)) + { + throw new CommandLineException($"Missing value for {arg}"); + } + + ParseLastValue(command, arg, value); + return true; + } + else if (arg == "--case-sensitive" || arg == "-c") + { + command.CaseSensitive = true; + return true; + } + else if (arg == "--regex" || arg == "-r") + { + command.UseRegex = true; + return true; + } + else if (arg == "--user-only" || arg == "-u") + { + command.UserOnly = true; + return true; + } + else if (arg == "--assistant-only" || arg == "-a") + { + command.AssistantOnly = true; + return true; + } + else if (arg == "--context" || arg == "-C") + { + var lines = i + 1 < args.Length ? args[++i] : null; + if (string.IsNullOrWhiteSpace(lines) || !int.TryParse(lines, out var n)) + { + throw new CommandLineException($"Missing or invalid context lines for {arg}"); + } + command.ContextLines = n; + return true; + } + + return false; + } + + private bool TryParseStatsCommandOptions(StatsCommand command, string[] args, ref int i, string arg) + { + // Try common display options first + if (TryParseDisplayOptions(command, args, ref i, arg)) + { + return true; + } + + // Try common time options + if (TryParseTimeOptions(command, args, ref i, arg)) + { + return true; + } + + if (arg == "--date" || arg == "-d") + { + var date = i + 1 < args.Length ? args[++i] : null; + if (string.IsNullOrWhiteSpace(date)) + { + throw new CommandLineException($"Missing date value for {arg}"); + } + command.Date = date; + return true; + } + else if (arg == "--last") + { + var value = i + 1 < args.Length ? args[++i] : null; + if (string.IsNullOrWhiteSpace(value)) + { + throw new CommandLineException($"Missing value for {arg}"); + } + + ParseLastValue(command, arg, value); + return true; + } + else if (arg == "--show-tools") + { + command.ShowTools = true; + return true; + } + else if (arg == "--no-dates") + { + command.ShowDates = false; + return true; + } + + return false; + } + + private bool TryParseCleanupCommandOptions(CleanupCommand command, string[] args, ref int i, string arg) + { + if (arg == "--find-duplicates") { command.FindDuplicates = true; return true; } + else if (arg == "--remove-duplicates") { command.RemoveDuplicates = true; command.FindDuplicates = true; return true; } + else if (arg == "--find-empty") { command.FindEmpty = true; return true; } + else if (arg == "--remove-empty") { command.RemoveEmpty = true; command.FindEmpty = true; return true; } + else if (arg == "--older-than-days") + { + var days = i + 1 < args.Length ? args[++i] : null; + if (string.IsNullOrWhiteSpace(days) || !int.TryParse(days, out var n)) + throw new CommandLineException($"Missing or invalid days for {arg}"); + command.OlderThanDays = n; + return true; + } + else if (arg == "--execute") { command.DryRun = false; return true; } + return false; + } + + /// + /// Parse a value for --last: either TIMESPEC or conversation count + /// For TIMESPEC like "7d", automatically makes it negative (7 days ago) and creates range + /// + private void ParseLastValue(CycoDjCommand command, string arg, string value) + { + // Smart detection: TIMESPEC vs conversation count + if (IsTimeSpec(value)) + { + // Parse as TIMESPEC + try + { + // For --last context, relative times should go BACKWARD (ago) + // If value is like "7d", convert to "-7d.." (7 days ago to now) + var timeSpec = value; + if (System.Text.RegularExpressions.Regex.IsMatch(value, @"^\d+[dhms]", System.Text.RegularExpressions.RegexOptions.IgnoreCase)) + { + timeSpec = "-" + value + ".."; // Make it a range from N ago to now + } + + var (after, before) = TimeSpecHelpers.ParseTimeSpecRange(arg, timeSpec); + command.After = after; + command.Before = before; + } + catch (Exception ex) + { + throw new CommandLineException($"Invalid time specification for --last: {value}\n{ex.Message}"); + } + } + else + { + // Parse as conversation count (for ListCommand, SearchCommand, etc.) + if (!int.TryParse(value, out var count)) + { + throw new CommandLineException($"Invalid number for --last: {value}"); + } + + // Set Last property if it exists on the command + var lastProp = command.GetType().GetProperty("Last"); + if (lastProp != null) + { + lastProp.SetValue(command, count); + } + } + } + + /// + /// Determines if a value is a TIMESPEC (vs. a plain number for conversation count) + /// + private static bool IsTimeSpec(string value) + { + if (string.IsNullOrWhiteSpace(value)) return false; + + // Has range syntax? + if (value.Contains("..")) return true; + + // Is keyword? + if (value.Equals("today", StringComparison.OrdinalIgnoreCase)) return true; + if (value.Equals("yesterday", StringComparison.OrdinalIgnoreCase)) return true; + + // Has time units (d, h, m, s)? + if (System.Text.RegularExpressions.Regex.IsMatch(value, @"[dhms]", System.Text.RegularExpressions.RegexOptions.IgnoreCase)) + return true; + + // Pure number = conversation count + return false; + } + +} diff --git a/src/cycodj/CommandLineCommands/BranchesCommand.cs b/src/cycodj/CommandLineCommands/BranchesCommand.cs new file mode 100644 index 00000000..7ce2e9f1 --- /dev/null +++ b/src/cycodj/CommandLineCommands/BranchesCommand.cs @@ -0,0 +1,341 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using CycoDj.Analyzers; +using CycoDj.CommandLine; +using CycoDj.Helpers; + +namespace CycoDj.CommandLineCommands; + +public class BranchesCommand : CycoDjCommand +{ + public string? Date { get; set; } + public string? Conversation { get; set; } + public bool Verbose { get; set; } = false; + public int Last { get; set; } = 0; + public int? MessageCount { get; set; } = null; // null = use default (0 for branches) + public bool ShowStats { get; set; } = false; + + public override async Task ExecuteAsync() + { + var output = GenerateBranchesOutput(); + + // Apply instructions if provided + var finalOutput = ApplyInstructionsIfProvided(output); + + // Save to file if --save-output was provided + if (SaveOutputIfRequested(finalOutput)) + { + return await Task.FromResult(0); + } + + // Otherwise print to console + ConsoleHelpers.WriteLine(finalOutput); + + return await Task.FromResult(0); + } + + private string GenerateBranchesOutput() + { + var sb = new System.Text.StringBuilder(); + + // Find all history files + var files = HistoryFileHelpers.FindAllHistoryFiles(); + + if (files.Count == 0) + { + sb.AppendLine("WARNING: No chat history files found"); + return sb.ToString(); + } + + // Filter by time range if After/Before are set + if (After.HasValue || Before.HasValue) + { + files = HistoryFileHelpers.FilterByDateRange(files, After, Before); + + if (After.HasValue && Before.HasValue) + { + sb.AppendLine($"Filtered by time range: {After:yyyy-MM-dd HH:mm} to {Before:yyyy-MM-dd HH:mm} ({files.Count} files)"); + } + else if (After.HasValue) + { + sb.AppendLine($"Filtered: after {After:yyyy-MM-dd HH:mm} ({files.Count} files)"); + } + else if (Before.HasValue) + { + sb.AppendLine($"Filtered: before {Before:yyyy-MM-dd HH:mm} ({files.Count} files)"); + } + sb.AppendLine(); + } + // Filter by date if specified (backward compat) + else if (!string.IsNullOrEmpty(Date)) + { + if (DateTime.TryParse(Date, out var dateFilter)) + { + files = HistoryFileHelpers.FilterByDate(files, dateFilter); + } + else + { + sb.AppendLine($"ERROR: Invalid date format: {Date}"); + return sb.ToString(); + } + } + + // Read conversations + var conversations = JsonlReader.ReadConversations(files); + + if (conversations.Count == 0) + { + sb.AppendLine("WARNING: No conversations could be read"); + return sb.ToString(); + } + + // Apply --last N limit if specified + if (Last > 0) + { + conversations = conversations + .OrderByDescending(c => c.Timestamp) + .Take(Last) + .OrderBy(c => c.Timestamp) + .ToList(); + } + + // Build conversation tree + var tree = BranchDetector.BuildTree(conversations); + + // If specific conversation requested, show just that branch + if (!string.IsNullOrEmpty(Conversation)) + { + AppendSingleConversationBranches(sb, tree); + return sb.ToString(); + } + + // Show full tree + sb.AppendLine("## Conversation Tree"); + sb.AppendLine(); + + if (tree.Roots.Count == 0) + { + sb.AppendLine("WARNING: No root conversations found (all conversations appear to be orphaned branches)"); + return sb.ToString(); + } + + // Display each root and its descendants + foreach (var root in tree.Roots.OrderBy(r => r.Timestamp)) + { + AppendConversationTree(sb, root, tree, 0); + } + + // Show statistics + sb.AppendLine(); + sb.AppendLine($"Total conversations: {tree.TotalConversations}"); + sb.AppendLine($"Root conversations: {tree.RootCount}"); + + var branchedCount = tree.AllConversations.Count(c => c.ParentId != null); + if (branchedCount > 0) + { + sb.AppendLine($"Branched conversations: {branchedCount}"); + } + + // Add detailed statistics if requested + if (ShowStats && tree.AllConversations.Any()) + { + sb.AppendLine(); + sb.AppendLine("═══════════════════════════════════════"); + sb.AppendLine("## Statistics Summary"); + sb.AppendLine("═══════════════════════════════════════"); + sb.AppendLine(); + + var totalMessages = tree.AllConversations.Sum(c => c.Messages.Count); + var totalUserMessages = tree.AllConversations.Sum(c => c.Messages.Count(m => m.Role == "user")); + var totalAssistantMessages = tree.AllConversations.Sum(c => c.Messages.Count(m => m.Role == "assistant")); + var totalToolMessages = tree.AllConversations.Sum(c => c.Messages.Count(m => m.Role == "tool")); + var avgMessages = totalMessages / (double)tree.AllConversations.Count(); + var avgDepth = tree.AllConversations.Average(c => (double)GetDepth(c, tree)); + + sb.AppendLine($"Total conversations: {tree.TotalConversations}"); + sb.AppendLine($"Root conversations: {tree.RootCount}"); + sb.AppendLine($"Branched conversations: {branchedCount} ({branchedCount * 100.0 / tree.TotalConversations:F1}%)"); + sb.AppendLine(); + sb.AppendLine($"Total messages: {totalMessages:N0}"); + sb.AppendLine($" User: {totalUserMessages:N0} ({totalUserMessages * 100.0 / totalMessages:F1}%)"); + sb.AppendLine($" Assistant: {totalAssistantMessages:N0} ({totalAssistantMessages * 100.0 / totalMessages:F1}%)"); + sb.AppendLine($" Tool: {totalToolMessages:N0} ({totalToolMessages * 100.0 / totalMessages:F1}%)"); + sb.AppendLine(); + sb.AppendLine($"Average messages/conversation: {avgMessages:F1}"); + sb.AppendLine($"Average branch depth: {avgDepth:F1}"); + sb.AppendLine(); + sb.AppendLine("═══════════════════════════════════════"); + } + + return sb.ToString(); + } + + private int GetDepth(Models.Conversation conv, Models.ConversationTree tree) + { + var depth = 0; + var current = conv; + while (current.ParentId != null && tree.ConversationLookup.TryGetValue(current.ParentId, out var parent)) + { + depth++; + current = parent; + } + return depth; + } + + private void AppendConversationTree(System.Text.StringBuilder sb, Models.Conversation conv, Models.ConversationTree tree, int depth) + { + var indent = new string(' ', depth * 2); + var branch = depth > 0 ? "├─ " : "📁 "; + + // Format timestamp + var timestamp = TimestampHelpers.FormatTimestamp(conv.Timestamp, "datetime"); + + // Show conversation + var title = conv.GetDisplayTitle(); + var displayTitle = title.Length > 60 ? title.Substring(0, 60) + "..." : title; + sb.AppendLine($"{indent}{branch}{timestamp} - {displayTitle}"); + + // Show verbose info if requested + if (Verbose) + { + var userCount = conv.Messages.Count(m => m.Role == "user"); + var assistantCount = conv.Messages.Count(m => m.Role == "assistant"); + + sb.AppendLine($"{indent} Messages: {userCount} user, {assistantCount} assistant"); + sb.AppendLine($"{indent} Tool calls: {conv.ToolCallIds.Count}"); + + if (conv.ParentId != null && tree.ConversationLookup.TryGetValue(conv.ParentId, out var parent)) + { + var commonLength = GetCommonPrefixLength(parent.ToolCallIds, conv.ToolCallIds); + var divergeAt = commonLength < parent.ToolCallIds.Count ? commonLength : parent.ToolCallIds.Count; + sb.AppendLine($"{indent} Branched at tool call #{divergeAt}"); + } + } + + // Show messages if requested + var messageCount = MessageCount ?? 0; // Default to 0 for branches + if (messageCount > 0) + { + var userMessages = conv.Messages.Where(m => m.Role == "user" && !string.IsNullOrWhiteSpace(m.Content)).ToList(); + + if (userMessages.Any()) + { + // For branches, show last N messages (what's new) + // For roots, show first N messages + var messagesToShow = conv.ParentId != null + ? userMessages.TakeLast(Math.Min(messageCount, userMessages.Count)) + : userMessages.Take(Math.Min(messageCount, userMessages.Count)); + + sb.AppendLine(); + foreach (var msg in messagesToShow) + { + var preview = msg.Content.Length > 150 + ? msg.Content.Substring(0, 150) + "..." + : msg.Content; + preview = preview.Replace("\n", " ").Replace("\r", ""); + + sb.AppendLine($"{indent} > {preview}"); + } + } + } + + // Recursively display children + var sortedBranchIds = conv.BranchIds + .Select(id => new { Id = id, Timestamp = tree.ConversationLookup.TryGetValue(id, out var tempConv) ? tempConv.Timestamp : DateTime.MinValue }) + .OrderBy(x => x.Timestamp) + .Select(x => x.Id) + .ToList(); + + foreach (var branchId in sortedBranchIds) + { + if (tree.ConversationLookup.TryGetValue(branchId, out var childBranch)) + { + AppendConversationTree(sb, childBranch, tree, depth + 1); + } + } + } + + private void AppendSingleConversationBranches(System.Text.StringBuilder sb, Models.ConversationTree tree) + { + // Find the conversation + var conv = tree.AllConversations.FirstOrDefault(c => + c.Id.Contains(Conversation!) || c.GetDisplayTitle().Contains(Conversation!)); + + if (conv == null) + { + sb.AppendLine($"ERROR: Conversation not found: {Conversation}"); + return; + } + + sb.AppendLine($"## Branches for: {conv.GetDisplayTitle()}"); + sb.AppendLine(); + + // Show parent chain + if (conv.ParentId != null) + { + sb.AppendLine("Parent chain:"); + AppendParentChain(sb, conv, tree); + sb.AppendLine(); + } + + // Show this conversation + var timestamp = TimestampHelpers.FormatTimestamp(conv.Timestamp, "datetime"); + sb.AppendLine($"📍 {timestamp} - {conv.GetDisplayTitle()}"); + sb.AppendLine($" Tool calls: {conv.ToolCallIds.Count}"); + sb.AppendLine(); + + // Show children + if (conv.BranchIds.Count > 0) + { + sb.AppendLine($"Branches ({conv.BranchIds.Count}):"); + foreach (var branchId in conv.BranchIds) + { + if (tree.ConversationLookup.TryGetValue(branchId, out var branch)) + { + var branchTimestamp = TimestampHelpers.FormatTimestamp(branch.Timestamp, "datetime"); + sb.AppendLine($" ├─ {branchTimestamp} - {branch.GetDisplayTitle()}"); + } + } + } + else + { + sb.AppendLine("No branches from this conversation"); + } + } + + private void AppendParentChain(System.Text.StringBuilder sb, Models.Conversation conv, Models.ConversationTree tree) + { + var chain = new System.Collections.Generic.List(); + var current = conv; + + while (current.ParentId != null && tree.ConversationLookup.TryGetValue(current.ParentId, out var parent)) + { + chain.Insert(0, parent); + current = parent; + } + + for (var i = 0; i < chain.Count; i++) + { + var indent = new string(' ', i * 2); + var timestamp = TimestampHelpers.FormatTimestamp(chain[i].Timestamp, "datetime"); + sb.AppendLine($"{indent}↑ {timestamp} - {chain[i].GetDisplayTitle()}"); + } + } + + private int GetCommonPrefixLength(System.Collections.Generic.List a, System.Collections.Generic.List b) + { + var length = 0; + var minLength = Math.Min(a.Count, b.Count); + + for (var i = 0; i < minLength; i++) + { + if (a[i] == b[i]) + length++; + else + break; + } + + return length; + } +} diff --git a/src/cycodj/CommandLineCommands/CleanupCommand.cs b/src/cycodj/CommandLineCommands/CleanupCommand.cs new file mode 100644 index 00000000..8c890a44 --- /dev/null +++ b/src/cycodj/CommandLineCommands/CleanupCommand.cs @@ -0,0 +1,269 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +namespace CycoDj.CommandLineCommands +{ + public class CleanupCommand : CommandLine.CycoDjCommand + { + public bool FindDuplicates { get; set; } + public bool RemoveDuplicates { get; set; } + public bool FindEmpty { get; set; } + public bool RemoveEmpty { get; set; } + public int? OlderThanDays { get; set; } + public bool DryRun { get; set; } = true; + + public override async Task ExecuteAsync() + { + ConsoleHelpers.WriteLine("## Chat History Cleanup", ConsoleColor.Cyan, overrideQuiet: true); + ConsoleHelpers.WriteLine(overrideQuiet: true); + + if (!FindDuplicates && !FindEmpty && !OlderThanDays.HasValue) + { + ConsoleHelpers.WriteErrorLine("Please specify at least one cleanup operation:"); + ConsoleHelpers.WriteLine(" --find-duplicates Find duplicate conversations", overrideQuiet: true); + ConsoleHelpers.WriteLine(" --find-empty Find empty conversations", overrideQuiet: true); + ConsoleHelpers.WriteLine(" --older-than-days N Find conversations older than N days", overrideQuiet: true); + return 1; + } + + var historyDir = CycoDj.Helpers.HistoryFileHelpers.GetHistoryDirectory(); + var files = CycoDj.Helpers.HistoryFileHelpers.FindAllHistoryFiles(); + + ConsoleHelpers.WriteLine($"Scanning {files.Count} conversation files...", overrideQuiet: true); + ConsoleHelpers.WriteLine(overrideQuiet: true); + + var toRemove = new List(); + + if (FindDuplicates || RemoveDuplicates) + { + toRemove.AddRange(await FindDuplicateConversationsAsync(files)); + } + + if (FindEmpty || RemoveEmpty) + { + toRemove.AddRange(FindEmptyConversations(files)); + } + + if (OlderThanDays.HasValue) + { + toRemove.AddRange(FindOldConversations(files, OlderThanDays.Value)); + } + + // Remove duplicates from the list + toRemove = toRemove.Distinct().ToList(); + + if (!toRemove.Any()) + { + ConsoleHelpers.WriteLine("✓ No files need cleanup!", ConsoleColor.Green, overrideQuiet: true); + return 0; + } + + ConsoleHelpers.WriteLine($"Found {toRemove.Count} file(s) to clean up:", ConsoleColor.Yellow, overrideQuiet: true); + ConsoleHelpers.WriteLine(overrideQuiet: true); + + foreach (var file in toRemove) + { + var size = new FileInfo(file).Length / 1024; + ConsoleHelpers.WriteLine($" - {Path.GetFileName(file)} ({size} KB)", ConsoleColor.DarkGray, overrideQuiet: true); + } + + ConsoleHelpers.WriteLine(overrideQuiet: true); + + if (DryRun && (RemoveDuplicates || RemoveEmpty)) + { + ConsoleHelpers.WriteLine("DRY RUN - No files will be deleted.", ConsoleColor.Yellow, overrideQuiet: true); + ConsoleHelpers.WriteLine("Add --execute to actually remove files.", overrideQuiet: true); + return 0; + } + + if (!DryRun && (RemoveDuplicates || RemoveEmpty)) + { + ConsoleHelpers.WriteLine("⚠️ WARNING: About to delete files!", ConsoleColor.Red, overrideQuiet: true); + ConsoleHelpers.Write("Type 'DELETE' to confirm: ", ConsoleColor.Yellow, overrideQuiet: true); + + var confirmation = Console.ReadLine(); + if (confirmation?.Trim().ToUpperInvariant() != "DELETE") + { + ConsoleHelpers.WriteLine("Cancelled.", overrideQuiet: true); + return 0; + } + + var deletedCount = 0; + var totalSize = 0L; + + foreach (var file in toRemove) + { + try + { + var size = new FileInfo(file).Length; + File.Delete(file); + deletedCount++; + totalSize += size; + ConsoleHelpers.WriteLine($" ✓ Deleted {Path.GetFileName(file)}", ConsoleColor.Green, overrideQuiet: true); + } + catch (Exception ex) + { + ConsoleHelpers.WriteErrorLine($" ✗ Failed to delete {Path.GetFileName(file)}: {ex.Message}"); + } + } + + ConsoleHelpers.WriteLine(overrideQuiet: true); + ConsoleHelpers.WriteLine($"Deleted {deletedCount} file(s), freed {totalSize / 1024 / 1024} MB", + ConsoleColor.Green, overrideQuiet: true); + } + + return 0; + } + + private async Task> FindDuplicateConversationsAsync(List files) + { + ConsoleHelpers.WriteLine("### Finding Duplicate Conversations", ConsoleColor.White, overrideQuiet: true); + ConsoleHelpers.WriteLine(overrideQuiet: true); + + var duplicates = new List(); + var conversationsByContent = new Dictionary>(); + + foreach (var file in files) + { + try + { + var conversation = CycoDj.Helpers.JsonlReader.ReadConversation(file); + if (conversation == null) continue; + + // Create a signature based on message content + var signature = string.Join("|", conversation.Messages + .Where(m => m.Role == "user" || m.Role == "assistant") + .Take(10) // First 10 messages + .Select(m => $"{m.Role}:{m.Content?.Length ?? 0}")); + + if (!conversationsByContent.ContainsKey(signature)) + { + conversationsByContent[signature] = new List(); + } + conversationsByContent[signature].Add(file); + } + catch (Exception ex) + { + Logger.Warning($"Failed to analyze {file}: {ex.Message}"); + } + } + + var duplicateGroups = conversationsByContent.Where(kv => kv.Value.Count > 1).ToList(); + + if (duplicateGroups.Any()) + { + ConsoleHelpers.WriteLine($"Found {duplicateGroups.Count} group(s) of duplicates:", overrideQuiet: true); + ConsoleHelpers.WriteLine(overrideQuiet: true); + + foreach (var group in duplicateGroups) + { + ConsoleHelpers.WriteLine($" Duplicate group ({group.Value.Count} files):", ConsoleColor.Yellow, overrideQuiet: true); + + // Keep the newest, mark others for removal + var sorted = group.Value.OrderByDescending(f => CycoDj.Helpers.TimestampHelpers.ParseTimestamp(f)).ToList(); + var keep = sorted.First(); + var remove = sorted.Skip(1).ToList(); + + ConsoleHelpers.WriteLine($" KEEP: {Path.GetFileName(keep)}", ConsoleColor.Green, overrideQuiet: true); + foreach (var file in remove) + { + ConsoleHelpers.WriteLine($" remove: {Path.GetFileName(file)}", ConsoleColor.DarkGray, overrideQuiet: true); + duplicates.Add(file); + } + ConsoleHelpers.WriteLine(overrideQuiet: true); + } + } + else + { + ConsoleHelpers.WriteLine(" No duplicates found.", ConsoleColor.Green, overrideQuiet: true); + } + + ConsoleHelpers.WriteLine(overrideQuiet: true); + return duplicates; + } + + private List FindEmptyConversations(List files) + { + ConsoleHelpers.WriteLine("### Finding Empty Conversations", ConsoleColor.White, overrideQuiet: true); + ConsoleHelpers.WriteLine(overrideQuiet: true); + + var empty = new List(); + + foreach (var file in files) + { + try + { + var conversation = CycoDj.Helpers.JsonlReader.ReadConversation(file); + if (conversation == null) continue; + + var meaningfulMessages = conversation.Messages.Count(m => + m.Role == "user" || m.Role == "assistant"); + + if (meaningfulMessages == 0) + { + empty.Add(file); + ConsoleHelpers.WriteLine($" Empty: {Path.GetFileName(file)}", ConsoleColor.Yellow, overrideQuiet: true); + } + } + catch (Exception ex) + { + Logger.Warning($"Failed to analyze {file}: {ex.Message}"); + } + } + + if (empty.Any()) + { + ConsoleHelpers.WriteLine($"Found {empty.Count} empty conversation(s).", ConsoleColor.Yellow, overrideQuiet: true); + } + else + { + ConsoleHelpers.WriteLine(" No empty conversations found.", ConsoleColor.Green, overrideQuiet: true); + } + + ConsoleHelpers.WriteLine(overrideQuiet: true); + return empty; + } + + private List FindOldConversations(List files, int olderThanDays) + { + ConsoleHelpers.WriteLine($"### Finding Conversations Older Than {olderThanDays} Days", ConsoleColor.White, overrideQuiet: true); + ConsoleHelpers.WriteLine(overrideQuiet: true); + + var cutoffDate = DateTime.Now.AddDays(-olderThanDays); + var old = new List(); + + foreach (var file in files) + { + try + { + var timestamp = CycoDj.Helpers.TimestampHelpers.ParseTimestamp(file); + if (timestamp < cutoffDate) + { + old.Add(file); + ConsoleHelpers.WriteLine($" Old: {Path.GetFileName(file)} ({timestamp:yyyy-MM-dd})", + ConsoleColor.DarkGray, overrideQuiet: true); + } + } + catch (Exception ex) + { + Logger.Warning($"Failed to analyze {file}: {ex.Message}"); + } + } + + if (old.Any()) + { + ConsoleHelpers.WriteLine($"Found {old.Count} old conversation(s).", ConsoleColor.Yellow, overrideQuiet: true); + } + else + { + ConsoleHelpers.WriteLine(" No old conversations found.", ConsoleColor.Green, overrideQuiet: true); + } + + ConsoleHelpers.WriteLine(overrideQuiet: true); + return old; + } + } +} diff --git a/src/cycodj/CommandLineCommands/ListCommand.cs b/src/cycodj/CommandLineCommands/ListCommand.cs new file mode 100644 index 00000000..20c36e2b --- /dev/null +++ b/src/cycodj/CommandLineCommands/ListCommand.cs @@ -0,0 +1,243 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using CycoDj.Analyzers; +using CycoDj.CommandLine; +using CycoDj.Helpers; + +namespace CycoDj.CommandLineCommands; + +public class ListCommand : CycoDjCommand +{ + public string? Date { get; set; } + public int Last { get; set; } = 0; + public bool ShowBranches { get; set; } = false; + public int? MessageCount { get; set; } = null; // null = use default (3) + public bool ShowStats { get; set; } = false; + + public override string GetHelpTopic() + { + // When list is the default command and --help is used without explicit "list", + // show the main usage help instead of list-specific help + return "usage"; + } + + public override async Task ExecuteAsync() + { + var output = GenerateListOutput(); + + // Apply instructions if provided + var finalOutput = ApplyInstructionsIfProvided(output); + + // Save to file if --save-output was provided + if (SaveOutputIfRequested(finalOutput)) + { + return await Task.FromResult(0); + } + + // Otherwise print to console + ConsoleHelpers.WriteLine(finalOutput); + + return await Task.FromResult(0); + } + + private string GenerateListOutput() + { + var sb = new System.Text.StringBuilder(); + + sb.AppendLine("## Chat History Conversations"); + sb.AppendLine(); + + // Find all history files + var files = HistoryFileHelpers.FindAllHistoryFiles(); + + if (files.Count == 0) + { + sb.AppendLine("WARNING: No chat history files found"); + var historyDir = HistoryFileHelpers.GetHistoryDirectory(); + sb.AppendLine($"Expected location: {historyDir}"); + return sb.ToString(); + } + + // Filter by time range if After/Before are set (from --today, --yesterday, --last , etc.) + if (After.HasValue || Before.HasValue) + { + files = HistoryFileHelpers.FilterByDateRange(files, After, Before); + + if (After.HasValue && Before.HasValue) + { + sb.AppendLine($"Filtered by time range: {After:yyyy-MM-dd HH:mm} to {Before:yyyy-MM-dd HH:mm} ({files.Count} files)"); + } + else if (After.HasValue) + { + sb.AppendLine($"Filtered: after {After:yyyy-MM-dd HH:mm} ({files.Count} files)"); + } + else if (Before.HasValue) + { + sb.AppendLine($"Filtered: before {Before:yyyy-MM-dd HH:mm} ({files.Count} files)"); + } + sb.AppendLine(); + } + // Filter by date if specified (backward compatibility) + else if (!string.IsNullOrEmpty(Date)) + { + if (DateTime.TryParse(Date, out var dateFilter)) + { + files = HistoryFileHelpers.FilterByDate(files, dateFilter); + sb.AppendLine($"Filtered by date: {dateFilter:yyyy-MM-dd} ({files.Count} files)"); + sb.AppendLine(); + } + else + { + sb.AppendLine($"ERROR: Invalid date format: {Date}"); + return sb.ToString(); + } + } + + // Apply sensible default limit if not specified and no filters + var effectiveLimit = Last; + if (effectiveLimit == 0 && !After.HasValue && !Before.HasValue && string.IsNullOrEmpty(Date)) + { + effectiveLimit = 20; // Default to last 20 conversations + sb.AppendLine($"Showing last {effectiveLimit} conversations (use --last N to change, or --date to filter)"); + sb.AppendLine(); + } + + // Limit to last N if specified or defaulted + if (effectiveLimit > 0 && files.Count > effectiveLimit) + { + files = files.Take(effectiveLimit).ToList(); + if (Last > 0) + { + sb.AppendLine($"Showing last {effectiveLimit} conversations"); + sb.AppendLine(); + } + } + + // Read and display conversations + var conversations = JsonlReader.ReadConversations(files); + + if (conversations.Count == 0) + { + sb.AppendLine("WARNING: No conversations could be read"); + return sb.ToString(); + } + + // Detect branches + BranchDetector.DetectBranches(conversations); + + // Display conversations + foreach (var conv in conversations) + { + var timestamp = TimestampHelpers.FormatTimestamp(conv.Timestamp, "datetime"); + var userCount = conv.Messages.Count(m => m.Role == "user"); + var assistantCount = conv.Messages.Count(m => m.Role == "assistant"); + var toolCount = conv.Messages.Count(m => m.Role == "tool"); + + // Show indent if this is a branch + var indent = conv.ParentId != null ? " ↳ " : ""; + + // Show timestamp and title + sb.Append($"{indent}{timestamp} - "); + + if (!string.IsNullOrEmpty(conv.Metadata?.Title)) + { + sb.Append($"{conv.Metadata.Title} "); + sb.AppendLine($"({conv.Id})"); + } + else + { + sb.AppendLine($"{conv.Id}"); + } + + sb.AppendLine($"{indent} Messages: {userCount} user, {assistantCount} assistant, {toolCount} tool"); + + // Show branch info if ShowBranches is enabled + if (ShowBranches) + { + if (conv.ParentId != null) + { + sb.AppendLine($"{indent} Branch of: {conv.ParentId}"); + } + if (conv.BranchIds.Count > 0) + { + sb.AppendLine($"{indent} Branches: {conv.BranchIds.Count}"); + } + if (conv.ToolCallIds.Count > 0) + { + sb.AppendLine($"{indent} Tool calls: {conv.ToolCallIds.Count}"); + } + } + + // Show preview - configurable number of messages + var messageCount = MessageCount ?? 3; // Default to 3 messages + var userMessages = conv.Messages.Where(m => m.Role == "user" && !string.IsNullOrWhiteSpace(m.Content)).ToList(); + + if (userMessages.Any() && messageCount > 0) + { + // For branches, show last N messages (what's new) + // For non-branches, show first N messages + var messagesToShow = conv.ParentId != null + ? userMessages.TakeLast(Math.Min(messageCount, userMessages.Count)) + : userMessages.Take(Math.Min(messageCount, userMessages.Count)); + + foreach (var msg in messagesToShow) + { + var preview = msg.Content.Length > 200 + ? msg.Content.Substring(0, 200) + "..." + : msg.Content; + preview = preview.Replace("\n", " ").Replace("\r", ""); + + sb.AppendLine($"{indent} > {preview}"); + } + + // Show indicator if there are more messages + var shownCount = messagesToShow.Count(); + if (userMessages.Count > shownCount) + { + sb.AppendLine($"{indent} ... and {userMessages.Count - shownCount} more"); + } + } + + sb.AppendLine(); + } + + sb.AppendLine($"Total: {conversations.Count} conversation(s)"); + + // Show branch statistics + var branchedConvs = conversations.Count(c => c.ParentId != null); + if (branchedConvs > 0) + { + sb.AppendLine($"Branches: {branchedConvs} conversation(s) are branches of others"); + } + + // Add statistics if requested + if (ShowStats && conversations.Any()) + { + sb.AppendLine(); + sb.AppendLine("═══════════════════════════════════════"); + sb.AppendLine("## Statistics Summary"); + sb.AppendLine("═══════════════════════════════════════"); + sb.AppendLine(); + + var totalMessages = conversations.Sum(c => c.Messages.Count); + var totalUserMessages = conversations.Sum(c => c.Messages.Count(m => m.Role == "user")); + var totalAssistantMessages = conversations.Sum(c => c.Messages.Count(m => m.Role == "assistant")); + var totalToolMessages = conversations.Sum(c => c.Messages.Count(m => m.Role == "tool")); + var avgMessages = totalMessages / (double)conversations.Count; + + sb.AppendLine($"Total conversations: {conversations.Count}"); + sb.AppendLine($"Total messages: {totalMessages:N0}"); + sb.AppendLine($" User: {totalUserMessages:N0} ({totalUserMessages * 100.0 / totalMessages:F1}%)"); + sb.AppendLine($" Assistant: {totalAssistantMessages:N0} ({totalAssistantMessages * 100.0 / totalMessages:F1}%)"); + sb.AppendLine($" Tool: {totalToolMessages:N0} ({totalToolMessages * 100.0 / totalMessages:F1}%)"); + sb.AppendLine(); + sb.AppendLine($"Average messages/conversation: {avgMessages:F1}"); + sb.AppendLine($"Branched conversations: {branchedConvs} ({branchedConvs * 100.0 / conversations.Count:F1}%)"); + sb.AppendLine(); + sb.AppendLine("═══════════════════════════════════════"); + } + + return sb.ToString(); + } +} diff --git a/src/cycodj/CommandLineCommands/SearchCommand.cs b/src/cycodj/CommandLineCommands/SearchCommand.cs new file mode 100644 index 00000000..aec8e07d --- /dev/null +++ b/src/cycodj/CommandLineCommands/SearchCommand.cs @@ -0,0 +1,307 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace CycoDj.CommandLineCommands +{ + public class SearchCommand : CommandLine.CycoDjCommand + { + public string? Query { get; set; } + public string? Date { get; set; } + public int? Last { get; set; } + public bool CaseSensitive { get; set; } + public bool UseRegex { get; set; } + public bool UserOnly { get; set; } + public bool AssistantOnly { get; set; } + public int ContextLines { get; set; } = 2; + public bool ShowBranches { get; set; } = false; + public int? MessageCount { get; set; } = null; // null = use default (3) + public bool ShowStats { get; set; } = false; + + + public override async System.Threading.Tasks.Task ExecuteAsync() + { + var output = GenerateSearchOutput(); + + // Apply instructions if provided + var finalOutput = ApplyInstructionsIfProvided(output); + + // Save to file if --save-output was provided + if (SaveOutputIfRequested(finalOutput)) + { + return await System.Threading.Tasks.Task.FromResult(0); + } + + // Otherwise print to console + ConsoleHelpers.WriteLine(finalOutput); + + return await System.Threading.Tasks.Task.FromResult(0); + } + + private string GenerateSearchOutput() + { + var sb = new System.Text.StringBuilder(); + + if (string.IsNullOrWhiteSpace(Query)) + { + sb.AppendLine("ERROR: Search query is required."); + return sb.ToString(); + } + + sb.AppendLine($"## Searching conversations for: \"{Query}\""); + sb.AppendLine(); + + // Find and parse conversations + var historyDir = CycoDj.Helpers.HistoryFileHelpers.GetHistoryDirectory(); + var files = CycoDj.Helpers.HistoryFileHelpers.FindAllHistoryFiles(); + + // Filter by time range if After/Before are set + if (After.HasValue || Before.HasValue) + { + files = CycoDj.Helpers.HistoryFileHelpers.FilterByDateRange(files, After, Before); + + if (After.HasValue && Before.HasValue) + { + sb.AppendLine($"Filtered by time range: {After:yyyy-MM-dd HH:mm} to {Before:yyyy-MM-dd HH:mm}"); + } + else if (After.HasValue) + { + sb.AppendLine($"Filtered: after {After:yyyy-MM-dd HH:mm}"); + } + else if (Before.HasValue) + { + sb.AppendLine($"Filtered: before {Before:yyyy-MM-dd HH:mm}"); + } + sb.AppendLine(); + } + // Filter by date if specified (backward compat) + else if (!string.IsNullOrWhiteSpace(Date)) + { + if (Date.ToLowerInvariant() == "today") + { + files = CycoDj.Helpers.HistoryFileHelpers.FilterByDate(files, DateTime.Today); + } + else if (DateTime.TryParse(Date, out var targetDate)) + { + files = CycoDj.Helpers.HistoryFileHelpers.FilterByDate(files, targetDate); + } + else + { + sb.AppendLine($"ERROR: Invalid date format: {Date}"); + return sb.ToString(); + } + } + + // Limit number of files if --last specified (as count) + if (Last.HasValue && Last.Value > 0) + { + files = files.OrderByDescending(f => CycoDj.Helpers.TimestampHelpers.ParseTimestamp(f)) + .Take(Last.Value) + .OrderBy(f => CycoDj.Helpers.TimestampHelpers.ParseTimestamp(f)) + .ToList(); + } + + if (!files.Any()) + { + sb.AppendLine("No conversations found matching the criteria."); + return sb.ToString(); + } + + // Parse conversations and search + var matches = new List<(Models.Conversation conversation, List searchMatches)>(); + + foreach (var file in files) + { + try + { + var conversation = CycoDj.Helpers.JsonlReader.ReadConversation(file); + if (conversation != null) + { + var conversationMatches = SearchConversation(conversation); + if (conversationMatches.Any()) + { + matches.Add((conversation, conversationMatches)); + } + } + } + catch (Exception ex) + { + Logger.Warning($"Failed to search conversation {file}: {ex.Message}"); + } + } + + // Display results + if (!matches.Any()) + { + sb.AppendLine("No matches found."); + return sb.ToString(); + } + + sb.AppendLine($"Found {matches.Count} conversation(s) with matches:"); + sb.AppendLine(); + + foreach (var (conversation, searchMatches) in matches) + { + AppendConversationMatches(sb, conversation, searchMatches); + } + + sb.AppendLine(); + sb.AppendLine($"Total: {matches.Sum(m => m.searchMatches.Count)} match(es) in {matches.Count} conversation(s)"); + + // Add statistics if requested + if (ShowStats) + { + var conversations = matches.Select(m => m.conversation).ToList(); + + sb.AppendLine(); + sb.AppendLine("═══════════════════════════════════════"); + sb.AppendLine("## Statistics Summary"); + sb.AppendLine("═══════════════════════════════════════"); + sb.AppendLine(); + + var totalMessages = conversations.Sum(c => c.Messages.Count); + var totalUserMessages = conversations.Sum(c => c.Messages.Count(m => m.Role == "user")); + var totalAssistantMessages = conversations.Sum(c => c.Messages.Count(m => m.Role == "assistant")); + var totalToolMessages = conversations.Sum(c => c.Messages.Count(m => m.Role == "tool")); + var avgMessages = totalMessages / (double)conversations.Count; + var branchCount = conversations.Count(c => c.ParentId != null); + + sb.AppendLine($"Total conversations searched: {files.Count}"); + sb.AppendLine($"Conversations with matches: {conversations.Count}"); + sb.AppendLine($"Total matches: {matches.Sum(m => m.searchMatches.Count)}"); + sb.AppendLine(); + sb.AppendLine($"Total messages: {totalMessages:N0}"); + sb.AppendLine($" User: {totalUserMessages:N0} ({totalUserMessages * 100.0 / totalMessages:F1}%)"); + sb.AppendLine($" Assistant: {totalAssistantMessages:N0} ({totalAssistantMessages * 100.0 / totalMessages:F1}%)"); + sb.AppendLine($" Tool: {totalToolMessages:N0} ({totalToolMessages * 100.0 / totalMessages:F1}%)"); + sb.AppendLine(); + sb.AppendLine($"Average messages/conversation: {avgMessages:F1}"); + sb.AppendLine($"Branched conversations: {branchCount} ({branchCount * 100.0 / conversations.Count:F1}%)"); + sb.AppendLine(); + sb.AppendLine("═══════════════════════════════════════"); + } + + return sb.ToString(); + } + + private List SearchConversation(Models.Conversation conversation) + { + var matches = new List(); + + for (int i = 0; i < conversation.Messages.Count; i++) + { + var message = conversation.Messages[i]; + + // Filter by role if specified + if (UserOnly && message.Role != "user") continue; + if (AssistantOnly && message.Role != "assistant") continue; + + // Skip system messages unless explicitly searching all + if (message.Role == "system" && (UserOnly || AssistantOnly)) continue; + + // Search in message content + if (!string.IsNullOrWhiteSpace(message.Content)) + { + var messageMatches = SearchText(message.Content); + if (messageMatches.Any()) + { + matches.Add(new SearchMatch + { + MessageIndex = i, + Message = message, + MatchedLines = messageMatches + }); + } + } + } + + return matches; + } + + private List<(int lineNumber, string line, int matchStart, int matchLength)> SearchText(string text) + { + var matches = new List<(int lineNumber, string line, int matchStart, int matchLength)>(); + var lines = text.Split(new[] { '\r', '\n' }, StringSplitOptions.None); + + for (int lineNum = 0; lineNum < lines.Length; lineNum++) + { + var line = lines[lineNum]; + if (string.IsNullOrEmpty(line)) continue; + + if (UseRegex) + { + try + { + var regexOptions = CaseSensitive ? RegexOptions.None : RegexOptions.IgnoreCase; + var regex = new Regex(Query!, regexOptions); + var match = regex.Match(line); + if (match.Success) + { + matches.Add((lineNum, line, match.Index, match.Length)); + } + } + catch (Exception ex) + { + ConsoleHelpers.WriteErrorLine($"Invalid regex pattern: {ex.Message}"); + return matches; + } + } + else + { + var comparison = CaseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + var index = line.IndexOf(Query!, comparison); + if (index >= 0) + { + matches.Add((lineNum, line, index, Query!.Length)); + } + } + } + + return matches; + } + + private void AppendConversationMatches(System.Text.StringBuilder sb, Models.Conversation conversation, List matches) + { + var title = conversation.Metadata?.Title ?? $"conversation-{conversation.Id}"; + var timestamp = conversation.Timestamp.ToString("yyyy-MM-dd HH:mm:ss"); + + sb.AppendLine($"### {timestamp} - {title}"); + sb.AppendLine($" File: {conversation.FilePath}"); + sb.AppendLine($" Matches: {matches.Count}"); + sb.AppendLine(); + + foreach (var match in matches) + { + var role = match.Message.Role; + sb.AppendLine($" [{role}] Message #{match.MessageIndex + 1}"); + + // Show matched lines with context + var allLines = match.Message.Content.Split(new[] { '\r', '\n' }, StringSplitOptions.None); + var matchedLineNumbers = match.MatchedLines.Select(m => m.lineNumber).Distinct().ToHashSet(); + + for (int i = 0; i < allLines.Length; i++) + { + var isMatch = matchedLineNumbers.Contains(i); + var showContext = matchedLineNumbers.Any(ln => Math.Abs(ln - i) <= ContextLines); + + if (showContext || isMatch) + { + var prefix = isMatch ? " > " : " "; + var line = allLines[i]; + sb.AppendLine(prefix + line); + } + } + + sb.AppendLine(); + } + } + + private class SearchMatch + { + public int MessageIndex { get; set; } + public Models.ChatMessage Message { get; set; } = null!; + public List<(int lineNumber, string line, int matchStart, int matchLength)> MatchedLines { get; set; } = new(); + } + } +} diff --git a/src/cycodj/CommandLineCommands/ShowCommand.cs b/src/cycodj/CommandLineCommands/ShowCommand.cs new file mode 100644 index 00000000..c39cb1db --- /dev/null +++ b/src/cycodj/CommandLineCommands/ShowCommand.cs @@ -0,0 +1,230 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using CycoDj.Analyzers; +using CycoDj.CommandLine; +using CycoDj.Helpers; + +namespace CycoDj.CommandLineCommands; + +public class ShowCommand : CycoDjCommand +{ + public string ConversationId { get; set; } = string.Empty; + public bool ShowToolCalls { get; set; } = false; + public bool ShowToolOutput { get; set; } = false; + public int MaxContentLength { get; set; } = 500; + public bool ShowStats { get; set; } = false; + + public override async Task ExecuteAsync() + { + var output = GenerateShowOutput(); + + // Apply instructions if provided + var finalOutput = ApplyInstructionsIfProvided(output); + + // Save to file if --save-output was provided + if (SaveOutputIfRequested(finalOutput)) + { + return await Task.FromResult(0); + } + + // Otherwise print to console + ConsoleHelpers.WriteLine(finalOutput); + + return await Task.FromResult(0); + } + + private string GenerateShowOutput() + { + var sb = new System.Text.StringBuilder(); + + if (string.IsNullOrEmpty(ConversationId)) + { + sb.AppendLine("ERROR: Conversation ID is required"); + sb.AppendLine("Usage: cycodj show "); + return sb.ToString(); + } + + // Find the conversation file + var files = HistoryFileHelpers.FindAllHistoryFiles(); + var matchingFile = files.FirstOrDefault(f => + f.Contains(ConversationId) || + System.IO.Path.GetFileNameWithoutExtension(f) == ConversationId); + + if (matchingFile == null) + { + sb.AppendLine($"ERROR: Conversation not found: {ConversationId}"); + sb.AppendLine($"Searched {files.Count} chat history files"); + return sb.ToString(); + } + + // Read the conversation + var conversation = JsonlReader.ReadConversation(matchingFile); + if (conversation == null) + { + sb.AppendLine($"ERROR: Failed to read conversation from: {matchingFile}"); + return sb.ToString(); + } + + // Load all conversations for branch detection + var allConversations = JsonlReader.ReadConversations(files); + BranchDetector.DetectBranches(allConversations); + + // Find our conversation in the list (with branch info populated) + var conv = allConversations.FirstOrDefault(c => c.Id == conversation.Id) ?? conversation; + + // Display header + sb.AppendLine("═".PadRight(80, '═')); + + if (!string.IsNullOrEmpty(conv.Metadata?.Title)) + { + sb.AppendLine($"## {conv.Metadata.Title}"); + } + else + { + sb.AppendLine($"## Conversation: {conv.Id}"); + } + + sb.AppendLine("═".PadRight(80, '═')); + sb.AppendLine(); + + // Display metadata + var timestamp = TimestampHelpers.FormatTimestamp(conv.Timestamp, "datetime"); + sb.AppendLine($"Timestamp: {timestamp}"); + sb.AppendLine($"File: {conv.FilePath}"); + sb.AppendLine($"Messages: {conv.Messages.Count} total"); + + var userCount = conv.Messages.Count(m => m.Role == "user"); + var assistantCount = conv.Messages.Count(m => m.Role == "assistant"); + var toolCount = conv.Messages.Count(m => m.Role == "tool"); + var systemCount = conv.Messages.Count(m => m.Role == "system"); + + sb.Append($" - {userCount} user, {assistantCount} assistant, {toolCount} tool"); + if (systemCount > 0) + { + sb.Append($", {systemCount} system"); + } + sb.AppendLine(); + + // Branch information + if (conv.ParentId != null) + { + sb.AppendLine($"Branch of: {conv.ParentId}"); + } + + if (conv.BranchIds.Count > 0) + { + sb.AppendLine($"Branches: {conv.BranchIds.Count} conversation(s) branch from this"); + foreach (var branchId in conv.BranchIds) + { + sb.AppendLine($" - {branchId}"); + } + } + + if (conv.ToolCallIds.Count > 0) + { + sb.AppendLine($"Tool Calls: {conv.ToolCallIds.Count}"); + } + + sb.AppendLine(); + sb.AppendLine("─".PadRight(80, '─')); + sb.AppendLine(); + + // Display messages + var messageNumber = 0; + foreach (var msg in conv.Messages) + { + messageNumber++; + + // Skip system messages unless verbose + if (msg.Role == "system" && !ConsoleHelpers.IsVerbose()) + { + sb.AppendLine($"[{messageNumber}] {msg.Role} (system prompt - use --verbose to show)"); + sb.AppendLine(); + continue; + } + + // Message header + sb.AppendLine($"[{messageNumber}] {msg.Role.ToUpper()}"); + + // Message content + var content = msg.Content ?? string.Empty; + + // Limit content length for tool outputs unless ShowToolOutput is enabled + if (msg.Role == "tool" && !ShowToolOutput && content.Length > MaxContentLength) + { + var truncated = content.Substring(0, MaxContentLength); + sb.AppendLine(truncated); + sb.AppendLine($"... (truncated {content.Length - MaxContentLength} chars, use --show-tool-output to see all)"); + } + else + { + // Limit other messages too for readability + if (content.Length > MaxContentLength * 3) + { + var truncated = content.Substring(0, MaxContentLength * 3); + sb.AppendLine(truncated); + sb.AppendLine($"... (truncated {content.Length - MaxContentLength * 3} chars)"); + } + else + { + sb.AppendLine(content); + } + } + + // Show tool calls if enabled + if (ShowToolCalls && msg.ToolCalls != null && msg.ToolCalls.Count > 0) + { + sb.AppendLine($"Tool Calls: {msg.ToolCalls.Count}"); + foreach (var toolCall in msg.ToolCalls) + { + sb.AppendLine($" - {toolCall.Id}: {toolCall.Function?.Name ?? "unknown"}"); + } + } + + // Show tool call ID for tool responses + if (msg.Role == "tool" && !string.IsNullOrEmpty(msg.ToolCallId)) + { + sb.AppendLine($"(responding to: {msg.ToolCallId})"); + } + + sb.AppendLine(); + } + + sb.AppendLine("─".PadRight(80, '─')); + sb.AppendLine($"End of conversation: {conv.Id}"); + + // Add statistics if requested + if (ShowStats) + { + sb.AppendLine(); + sb.AppendLine("═══════════════════════════════════════"); + sb.AppendLine("## Conversation Statistics"); + sb.AppendLine("═══════════════════════════════════════"); + sb.AppendLine(); + + var totalMessages = conv.Messages.Count; + var totalUserMessages = conv.Messages.Count(m => m.Role == "user"); + var totalAssistantMessages = conv.Messages.Count(m => m.Role == "assistant"); + var totalToolMessages = conv.Messages.Count(m => m.Role == "tool"); + var toolCalls = conv.Messages.Where(m => m.ToolCalls != null).Sum(m => m.ToolCalls!.Count); + + sb.AppendLine($"Total messages: {totalMessages}"); + sb.AppendLine($" User: {totalUserMessages} ({totalUserMessages * 100.0 / totalMessages:F1}%)"); + sb.AppendLine($" Assistant: {totalAssistantMessages} ({totalAssistantMessages * 100.0 / totalMessages:F1}%)"); + sb.AppendLine($" Tool: {totalToolMessages} ({totalToolMessages * 100.0 / totalMessages:F1}%)"); + sb.AppendLine(); + sb.AppendLine($"Tool calls: {toolCalls}"); + + if (!string.IsNullOrEmpty(conv.ParentId)) + { + sb.AppendLine($"This is a branch (parent: {conv.ParentId})"); + } + + sb.AppendLine(); + sb.AppendLine("═══════════════════════════════════════"); + } + + return sb.ToString(); + } +} diff --git a/src/cycodj/CommandLineCommands/StatsCommand.cs b/src/cycodj/CommandLineCommands/StatsCommand.cs new file mode 100644 index 00000000..65b934f9 --- /dev/null +++ b/src/cycodj/CommandLineCommands/StatsCommand.cs @@ -0,0 +1,218 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace CycoDj.CommandLineCommands +{ + public class StatsCommand : CommandLine.CycoDjCommand + { + public string? Date { get; set; } + public int? Last { get; set; } + public bool ShowTools { get; set; } + public bool ShowDates { get; set; } = true; + + public override async Task ExecuteAsync() + { + var output = GenerateStatsOutput(); + + // Apply instructions if provided + var finalOutput = ApplyInstructionsIfProvided(output); + + // Save to file if --save-output was provided + if (SaveOutputIfRequested(finalOutput)) + { + return await Task.FromResult(0); + } + + // Otherwise print to console + ConsoleHelpers.WriteLine(finalOutput); + + return await Task.FromResult(0); + } + + private string GenerateStatsOutput() + { + var sb = new System.Text.StringBuilder(); + + sb.AppendLine("## Chat History Statistics"); + sb.AppendLine(); + + // Find and parse conversations + var historyDir = CycoDj.Helpers.HistoryFileHelpers.GetHistoryDirectory(); + var files = CycoDj.Helpers.HistoryFileHelpers.FindAllHistoryFiles(); + + // Filter by time range if After/Before are set + if (After.HasValue || Before.HasValue) + { + files = CycoDj.Helpers.HistoryFileHelpers.FilterByDateRange(files, After, Before); + } + // Filter by date if specified (backward compat) + else if (!string.IsNullOrWhiteSpace(Date)) + { + if (Date.ToLowerInvariant() == "today") + { + files = CycoDj.Helpers.HistoryFileHelpers.FilterByDate(files, DateTime.Today); + } + else if (DateTime.TryParse(Date, out var targetDate)) + { + files = CycoDj.Helpers.HistoryFileHelpers.FilterByDate(files, targetDate); + } + else + { + sb.AppendLine($"ERROR: Invalid date format: {Date}"); + return sb.ToString(); + } + } + + // Limit number of files if --last specified (as count) + if (Last.HasValue && Last.Value > 0) + { + files = files.OrderByDescending(f => CycoDj.Helpers.TimestampHelpers.ParseTimestamp(f)) + .Take(Last.Value) + .ToList(); + } + + if (!files.Any()) + { + sb.AppendLine("No conversations found."); + return sb.ToString(); + } + + // Parse conversations + var conversations = new List(); + foreach (var file in files) + { + try + { + var conversation = CycoDj.Helpers.JsonlReader.ReadConversation(file); + if (conversation != null) + { + conversations.Add(conversation); + } + } + catch (Exception ex) + { + Logger.Warning($"Failed to load conversation {file}: {ex.Message}"); + } + } + + // Calculate statistics + AppendOverallStats(sb, conversations); + + if (ShowDates) + { + sb.AppendLine(); + AppendDateStats(sb, conversations); + } + + if (ShowTools) + { + sb.AppendLine(); + AppendToolUsageStats(sb, conversations); + } + + return sb.ToString(); + } + + private void AppendOverallStats(System.Text.StringBuilder sb, List conversations) + { + sb.AppendLine("### Overall Statistics"); + sb.AppendLine(); + + var totalConversations = conversations.Count; + var totalMessages = conversations.Sum(c => c.Messages.Count); + var totalUserMessages = conversations.Sum(c => c.Messages.Count(m => m.Role == "user")); + var totalAssistantMessages = conversations.Sum(c => c.Messages.Count(m => m.Role == "assistant")); + var totalToolMessages = conversations.Sum(c => c.Messages.Count(m => m.Role == "tool")); + + var avgMessagesPerConv = totalMessages / (double)totalConversations; + var avgUserPerConv = totalUserMessages / (double)totalConversations; + + sb.AppendLine($"**Conversations:** {totalConversations:#,##0}"); + sb.AppendLine($"**Total Messages:** {totalMessages:#,##0}"); + sb.AppendLine($" - User: {totalUserMessages:#,##0} ({totalUserMessages * 100.0 / totalMessages:F1}%)"); + sb.AppendLine($" - Assistant: {totalAssistantMessages:#,##0} ({totalAssistantMessages * 100.0 / totalMessages:F1}%)"); + sb.AppendLine($" - Tool: {totalToolMessages:#,##0} ({totalToolMessages * 100.0 / totalMessages:F1}%)"); + sb.AppendLine(); + sb.AppendLine($"**Average per conversation:**"); + sb.AppendLine($" - Messages: {avgMessagesPerConv:F1}"); + sb.AppendLine($" - User messages: {avgUserPerConv:F1}"); + + // Find longest conversation + var longest = conversations.OrderByDescending(c => c.Messages.Count).First(); + sb.AppendLine(); + sb.AppendLine($"**Longest conversation:** {longest.Messages.Count} messages"); + sb.AppendLine($" {longest.Timestamp:yyyy-MM-dd HH:mm:ss} - {longest.Metadata?.Title ?? longest.Id}"); + } + + private void AppendDateStats(System.Text.StringBuilder sb, List conversations) + { + sb.AppendLine("### Activity by Date"); + sb.AppendLine(); + + var byDate = conversations + .GroupBy(c => c.Timestamp.Date) + .OrderByDescending(g => g.Key) + .Take(10) + .ToList(); + + sb.AppendLine($"{"Date",-12} {"Convs",6} {"Msgs",7} {"User",6} {"Asst",6} {"Tool",6}"); + sb.AppendLine(new string('-', 50)); + + foreach (var group in byDate) + { + var convCount = group.Count(); + var msgCount = group.Sum(c => c.Messages.Count); + var userCount = group.Sum(c => c.Messages.Count(m => m.Role == "user")); + var asstCount = group.Sum(c => c.Messages.Count(m => m.Role == "assistant")); + var toolCount = group.Sum(c => c.Messages.Count(m => m.Role == "tool")); + + var dateStr = group.Key.ToString("yyyy-MM-dd"); + sb.AppendLine($"{dateStr,-12} {convCount,6} {msgCount,7} {userCount,6} {asstCount,6} {toolCount,6}"); + } + } + + private void AppendToolUsageStats(System.Text.StringBuilder sb, List conversations) + { + sb.AppendLine("### Tool Usage Statistics"); + sb.AppendLine(); + + // Collect all tool calls + var toolCalls = new Dictionary(); + foreach (var conversation in conversations) + { + foreach (var message in conversation.Messages) + { + if (message.ToolCalls != null) + { + foreach (var toolCall in message.ToolCalls) + { + var toolName = toolCall.Function?.Name ?? "Unknown"; + toolCalls[toolName] = toolCalls.GetValueOrDefault(toolName, 0) + 1; + } + } + } + } + + if (!toolCalls.Any()) + { + sb.AppendLine("No tool usage found."); + return; + } + + var totalToolCalls = toolCalls.Values.Sum(); + sb.AppendLine($"**Total tool calls:** {totalToolCalls:#,##0}"); + sb.AppendLine(); + + sb.AppendLine($"{"Tool Name",-40} {"Count",8} {"%",6}"); + sb.AppendLine(new string('-', 56)); + + foreach (var tool in toolCalls.OrderByDescending(kv => kv.Value).Take(20)) + { + var percentage = tool.Value * 100.0 / totalToolCalls; + sb.AppendLine($"{tool.Key,-40} {tool.Value,8:#,##0} {percentage,5:F1}%"); + } + } + } +} diff --git a/src/cycodj/CycoDjProgramInfo.cs b/src/cycodj/CycoDjProgramInfo.cs new file mode 100644 index 00000000..35e784f9 --- /dev/null +++ b/src/cycodj/CycoDjProgramInfo.cs @@ -0,0 +1,10 @@ +public class CycoDjProgramInfo : ProgramInfo +{ + public CycoDjProgramInfo() : base( + () => "cycodj", + () => "Chat History Journal and Analysis Tool", + () => ".cycod", + () => typeof(CycoDjProgramInfo).Assembly) + { + } +} diff --git a/src/cycodj/Helpers/HistoryFileHelpers.cs b/src/cycodj/Helpers/HistoryFileHelpers.cs new file mode 100644 index 00000000..9a3b4ffe --- /dev/null +++ b/src/cycodj/Helpers/HistoryFileHelpers.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace CycoDj.Helpers; + +public static class HistoryFileHelpers +{ + /// + /// Gets the history directory path for the user + /// + public static string GetHistoryDirectory() + { + var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return Path.Combine(userProfile, ".cycod", "history"); + } + + /// + /// Finds all chat history files in the user's history directory + /// + public static List FindAllHistoryFiles() + { + var historyDir = GetHistoryDirectory(); + + if (!Directory.Exists(historyDir)) + { + Logger.Warning($"History directory not found: {historyDir}"); + return new List(); + } + + try + { + var files = Directory.GetFiles(historyDir, "chat-history-*.jsonl") + .OrderByDescending(f => f) + .ToList(); + + Logger.Info($"Found {files.Count} chat history files"); + return files; + } + catch (Exception ex) + { + Logger.Error($"Error reading history directory: {ex.Message}"); + return new List(); + } + } + + /// + /// Filters files by date range + /// + public static List FilterByDateRange(List files, DateTime? after, DateTime? before) + { + return files.Where(f => + { + var timestamp = TimestampHelpers.ParseTimestamp(f); + if (timestamp == DateTime.MinValue) return false; + + if (after.HasValue && timestamp < after.Value) return false; + if (before.HasValue && timestamp > before.Value) return false; + + return true; + }).ToList(); + } + + /// + /// Filters files by specific date (ignores time component) + /// + public static List FilterByDate(List files, DateTime date) + { + return files.Where(f => + { + var timestamp = TimestampHelpers.ParseTimestamp(f); + return timestamp.Date == date.Date; + }).ToList(); + } +} diff --git a/src/cycodj/Helpers/JsonlReader.cs b/src/cycodj/Helpers/JsonlReader.cs new file mode 100644 index 00000000..a7fe2bb3 --- /dev/null +++ b/src/cycodj/Helpers/JsonlReader.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using CycoDj.Models; + +namespace CycoDj.Helpers; + +public static class JsonlReader +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + /// + /// Reads a conversation from a JSONL file + /// + public static Conversation? ReadConversation(string filePath) + { + try + { + if (!File.Exists(filePath)) + { + Logger.Warning($"File not found: {filePath}"); + return null; + } + + var lines = File.ReadAllLines(filePath); + if (lines.Length == 0) + { + Logger.Warning($"File is empty: {Path.GetFileName(filePath)}"); + return null; + } + + Logger.Info($"Reading {lines.Length} lines from {Path.GetFileName(filePath)}"); + + // Check if first line contains metadata + var (metadata, messageStartIndex) = TryParseMetadata(lines[0]); + + if (metadata != null) + { + Logger.Info($"Found metadata with title: {metadata.Title ?? "(no title)"}"); + } + + var messages = new List(); + + // Parse messages starting from the appropriate index + for (int i = messageStartIndex; i < lines.Length; i++) + { + var line = lines[i]; + if (string.IsNullOrWhiteSpace(line)) continue; + + try + { + var msg = JsonSerializer.Deserialize(line, JsonOptions); + if (msg != null) + { + messages.Add(msg); + } + } + catch (JsonException ex) + { + Logger.Warning($"Failed to parse line {i + 1}: {ex.Message}"); + // Continue parsing other lines + } + } + + var conversation = new Conversation + { + Id = Path.GetFileNameWithoutExtension(filePath), + FilePath = filePath, + Timestamp = TimestampHelpers.ParseTimestamp(filePath), + Metadata = metadata, + Messages = messages + }; + + // Extract tool_call_ids for branch detection + conversation.ToolCallIds = messages + .Where(m => !string.IsNullOrEmpty(m.ToolCallId)) + .Select(m => m.ToolCallId!) + .ToList(); + + Logger.Info($"Parsed conversation with {messages.Count} messages, {conversation.ToolCallIds.Count} tool call IDs"); + + return conversation; + } + catch (Exception ex) + { + Logger.Error($"Error reading {filePath}: {ex.Message}"); + return null; + } + } + + /// + /// Attempts to parse metadata from the first line of a JSONL file + /// + private static (ConversationMetadata? metadata, int messageStartIndex) TryParseMetadata(string firstLine) + { + if (string.IsNullOrWhiteSpace(firstLine) || !firstLine.TrimStart().StartsWith("{\"_meta\":")) + { + return (null, 0); + } + + try + { + var wrapper = JsonSerializer.Deserialize(firstLine, JsonOptions); + if (wrapper?._meta != null) + { + return (wrapper._meta, 1); // Skip first line, start messages at line 1 + } + } + catch (JsonException ex) + { + Logger.Warning($"Malformed metadata in first line: {ex.Message}"); + } + + return (null, 0); // Treat first line as regular message + } + + /// + /// Reads multiple conversations from files + /// + public static List ReadConversations(List filePaths) + { + var conversations = new List(); + + foreach (var file in filePaths) + { + var conv = ReadConversation(file); + if (conv != null) + { + conversations.Add(conv); + } + } + + Logger.Info($"Successfully read {conversations.Count} of {filePaths.Count} conversations"); + return conversations; + } +} diff --git a/src/cycodj/Helpers/TimestampHelpers.cs b/src/cycodj/Helpers/TimestampHelpers.cs new file mode 100644 index 00000000..2f81d42a --- /dev/null +++ b/src/cycodj/Helpers/TimestampHelpers.cs @@ -0,0 +1,45 @@ +using System; +using System.IO; + +namespace CycoDj.Helpers; + +public static class TimestampHelpers +{ + /// + /// Extracts timestamp from filename like "chat-history-1754437373970.jsonl" + /// + public static DateTime ParseTimestamp(string filename) + { + try + { + var name = Path.GetFileNameWithoutExtension(filename); + var parts = name.Split('-'); + + // Expected format: chat-history-{timestamp} + if (parts.Length >= 3 && long.TryParse(parts[2], out var timestamp)) + { + return DateTimeOffset.FromUnixTimeMilliseconds(timestamp).LocalDateTime; + } + } + catch + { + // Fall through to return MinValue + } + + return DateTime.MinValue; + } + + /// + /// Formats timestamp for display + /// + public static string FormatTimestamp(DateTime dt, string format = "default") + { + return format switch + { + "short" => dt.ToString("HH:mm:ss"), + "date" => dt.ToString("yyyy-MM-dd"), + "datetime" => dt.ToString("yyyy-MM-dd HH:mm:ss"), + _ => dt.ToString("yyyy-MM-dd HH:mm:ss") + }; + } +} diff --git a/src/cycodj/Models/ChatMessage.cs b/src/cycodj/Models/ChatMessage.cs new file mode 100644 index 00000000..fe247fad --- /dev/null +++ b/src/cycodj/Models/ChatMessage.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace CycoDj.Models; + +public class ChatMessage +{ + [JsonPropertyName("role")] + public string Role { get; set; } = ""; + + [JsonPropertyName("content")] + public string Content { get; set; } = ""; + + [JsonPropertyName("tool_calls")] + public List? ToolCalls { get; set; } + + [JsonPropertyName("tool_call_id")] + public string? ToolCallId { get; set; } +} diff --git a/src/cycodj/Models/Conversation.cs b/src/cycodj/Models/Conversation.cs new file mode 100644 index 00000000..cf550638 --- /dev/null +++ b/src/cycodj/Models/Conversation.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; + +namespace CycoDj.Models; + +public class Conversation +{ + public string Id { get; set; } = ""; + public string FilePath { get; set; } = ""; + public DateTime Timestamp { get; set; } + public ConversationMetadata? Metadata { get; set; } + public List Messages { get; set; } = new(); + public List ToolCallIds { get; set; } = new(); + + // For branch detection + public string? ParentId { get; set; } + public List BranchIds { get; set; } = new(); + + /// + /// Gets the display title, with fallback to ID if no title in metadata + /// + public string GetDisplayTitle() + { + if (!string.IsNullOrEmpty(Metadata?.Title)) + { + return Metadata.Title; + } + return Id; + } +} diff --git a/src/cycodj/Models/ConversationMetadata.cs b/src/cycodj/Models/ConversationMetadata.cs new file mode 100644 index 00000000..90528b6a --- /dev/null +++ b/src/cycodj/Models/ConversationMetadata.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CycoDj.Models; + +/// +/// Represents metadata for a conversation file. +/// +public class ConversationMetadata +{ + /// + /// Human-readable conversation title. + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// If true, AI should never regenerate title (user has manually edited it). + /// + [JsonPropertyName("titleLocked")] + public bool IsTitleLocked { get; set; } + + /// + /// Preserves unknown properties for future extensibility. + /// + [JsonExtensionData] + public Dictionary AdditionalProperties { get; set; } = new(); +} + +/// +/// Wrapper class for metadata serialization in JSONL format. +/// +internal class MetadataWrapper +{ + [JsonPropertyName("_meta")] + public ConversationMetadata? _meta { get; set; } +} diff --git a/src/cycodj/Models/ConversationTree.cs b/src/cycodj/Models/ConversationTree.cs new file mode 100644 index 00000000..2649d9e7 --- /dev/null +++ b/src/cycodj/Models/ConversationTree.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; + +namespace CycoDj.Models; + +/// +/// Represents a tree structure of conversations showing branching relationships. +/// +public class ConversationTree +{ + /// + /// Root conversations (those without parents). + /// + public List Roots { get; set; } = new(); + + /// + /// Lookup dictionary for quick access to conversations by ID. + /// + public Dictionary ConversationLookup { get; set; } = new(); + + /// + /// Gets all conversations in the tree. + /// + public IEnumerable AllConversations => ConversationLookup.Values; + + /// + /// Gets total number of conversations in the tree. + /// + public int TotalConversations => ConversationLookup.Count; + + /// + /// Gets number of root conversations. + /// + public int RootCount => Roots.Count; +} diff --git a/src/cycodj/Models/ToolCall.cs b/src/cycodj/Models/ToolCall.cs new file mode 100644 index 00000000..71567564 --- /dev/null +++ b/src/cycodj/Models/ToolCall.cs @@ -0,0 +1,16 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CycoDj.Models; + +public class ToolCall +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("type")] + public string Type { get; set; } = ""; + + [JsonPropertyName("function")] + public ToolFunction? Function { get; set; } +} diff --git a/src/cycodj/Models/ToolFunction.cs b/src/cycodj/Models/ToolFunction.cs new file mode 100644 index 00000000..506ded11 --- /dev/null +++ b/src/cycodj/Models/ToolFunction.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace CycoDj.Models; + +public class ToolFunction +{ + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("arguments")] + public string Arguments { get; set; } = ""; +} diff --git a/src/cycodj/Program.cs b/src/cycodj/Program.cs new file mode 100644 index 00000000..23da93ed --- /dev/null +++ b/src/cycodj/Program.cs @@ -0,0 +1,111 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using CycoDj.CommandLine; + +class Program +{ + static async Task Main(string[] args) + { + CycoDjProgramInfo _programInfo = new(); + + // Hidden test command for smoke testing (check before parsing) + if (args.Length > 0 && args[0] == "--test") + { + CycoDj.Tests.ContentSummarizerSmokeTest.RunTests(); + return 0; + } + + LoggingInitializer.InitializeMemoryLogger(); + LoggingInitializer.LogStartupDetails(args); + Logger.Info($"Starting {ProgramInfo.Name}, version {VersionInfo.GetVersion()}"); + + if (!CycoDjCommandLineOptions.Parse(args, out var commandLineOptions, out var ex)) + { + DisplayBanner(); + if (ex != null) + { + Logger.Error($"Command line error: {ex.Message}"); + DisplayException(ex); + HelpHelpers.DisplayUsage(ex.GetHelpTopic()); + return 2; + } + else + { + Logger.Warning("Displaying help due to command line parsing issue"); + HelpHelpers.DisplayUsage(commandLineOptions!.HelpTopic); + return 1; + } + } + + var debug = ConsoleHelpers.IsDebug() || commandLineOptions!.Debug; + var verbose = ConsoleHelpers.IsVerbose() || commandLineOptions!.Verbose; + var quiet = ConsoleHelpers.IsQuiet() || commandLineOptions!.Quiet; + ConsoleHelpers.Configure(debug, verbose, quiet); + + LoggingInitializer.InitializeLogging(commandLineOptions?.LogFile, debug); + + var helpCommand = commandLineOptions!.Commands.OfType().FirstOrDefault(); + if (helpCommand != null) + { + DisplayBanner(); + HelpHelpers.DisplayHelpTopic(commandLineOptions.HelpTopic, commandLineOptions.ExpandHelpTopics); + return 0; + } + + var versionCommand = commandLineOptions!.Commands.OfType().FirstOrDefault(); + if (versionCommand != null) + { + DisplayBanner(); + var version = await versionCommand.ExecuteAsync(false); + ConsoleHelpers.WriteLine(version.ToString()!); + return 0; + } + + foreach (var command in commandLineOptions.Commands) + { + if (command is CycoDj.CommandLineCommands.ListCommand listCommand) + { + return await listCommand.ExecuteAsync(); + } + else if (command is CycoDj.CommandLineCommands.ShowCommand showCommand) + { + return await showCommand.ExecuteAsync(); + } + else if (command is CycoDj.CommandLineCommands.BranchesCommand branchesCommand) + { + return await branchesCommand.ExecuteAsync(); + } + else if (command is CycoDj.CommandLineCommands.SearchCommand searchCommand) + { + await searchCommand.ExecuteAsync(); + return 0; + } + else if (command is CycoDj.CommandLineCommands.StatsCommand statsCommand) + { + return await statsCommand.ExecuteAsync(); + } + else if (command is CycoDj.CommandLineCommands.CleanupCommand cleanupCommand) + { + return await cleanupCommand.ExecuteAsync(); + } + } + + return 0; + } + + private static void DisplayBanner() + { + ConsoleHelpers.WriteLine($"{ProgramInfo.Name} {VersionInfo.GetVersion()}", ConsoleColor.White); + } + + private static void DisplayException(Exception ex) + { + ConsoleHelpers.WriteErrorLine(ex.Message); + if (ConsoleHelpers.IsDebug()) + { + ConsoleHelpers.WriteErrorLine(ex.StackTrace ?? string.Empty); + } + } +} + diff --git a/src/cycodj/README.md b/src/cycodj/README.md new file mode 100644 index 00000000..f2f72d69 --- /dev/null +++ b/src/cycodj/README.md @@ -0,0 +1,55 @@ +# cycodj - Chat History Journal Tool + +A CLI tool for analyzing and journaling cycod chat history files. + +## Installation + +```bash +dotnet tool install -g CycoDj +``` + +## Usage + +### List Command (Phase 1 - IMPLEMENTED) + +```bash +cycodj list # List all conversations +cycodj list --date 2025-12-20 # Filter by date +cycodj list --last 10 # Show last N conversations +``` + +### Coming Soon + +```bash +cycodj show # Show conversation details +cycodj journal # Generate daily journal +cycodj branches # Show conversation tree +``` + +## Current Features (Phase 1) + +- ✅ Read and parse JSONL chat history files from `~/.cycod/history/` +- ✅ Extract conversation metadata (title, timestamps) +- ✅ Parse messages by role (user, assistant, tool, system) +- ✅ Display message counts and first user message preview +- ✅ Filter conversations by date (`--date YYYY-MM-DD`) +- ✅ Limit output to last N conversations (`--last N`) +- ✅ Color-coded console output for readability + +## Planned Features + +- Branch detection and visualization +- Daily journal generation +- Content summarization +- Search across conversations +- Export to markdown + +## Documentation + +- Project Documentation: [docs/](../../docs/) +- Implementation Plan: [docs/chat-journal-plan.md](../../docs/chat-journal-plan.md) +- Quick Start Guide: [docs/quick-start.md](../../docs/quick-start.md) + +## License + +MIT diff --git a/src/cycodj/Tests/ContentSummarizerSmokeTest.cs b/src/cycodj/Tests/ContentSummarizerSmokeTest.cs new file mode 100644 index 00000000..6bd77266 --- /dev/null +++ b/src/cycodj/Tests/ContentSummarizerSmokeTest.cs @@ -0,0 +1,185 @@ +using System; +using System.Linq; +using CycoDj.Analyzers; +using CycoDj.Helpers; +using CycoDj.Models; + +namespace CycoDj.Tests; + +/// +/// Simple smoke tests to verify ContentSummarizer works with real data. +/// +public class ContentSummarizerSmokeTest +{ + public static void RunTests() + { + Console.WriteLine("=== ContentSummarizer Smoke Tests ===\n"); + + // Test 1: Load a real conversation + Console.WriteLine("Test 1: Loading real conversation..."); + var files = HistoryFileHelpers.FindAllHistoryFiles(); + if (files.Count == 0) + { + Console.WriteLine(" ❌ No history files found. Cannot test."); + return; + } + + var testFile = files.First(); + Console.WriteLine($" Using: {System.IO.Path.GetFileName(testFile)}"); + + var conv = JsonlReader.ReadConversation(testFile); + if (conv == null) + { + Console.WriteLine(" ❌ Failed to load conversation"); + return; + } + Console.WriteLine($" ✓ Loaded conversation with {conv.Messages?.Count ?? 0} messages\n"); + + // Test 2: GetUserMessages + Console.WriteLine("Test 2: GetUserMessages()..."); + try + { + var userMsgs = ContentSummarizer.GetUserMessages(conv); + Console.WriteLine($" ✓ Found {userMsgs.Count} user messages"); + if (userMsgs.Count > 0) + { + var first = userMsgs.First(); + var preview = first.Length > 50 ? first.Substring(0, 50) + "..." : first; + Console.WriteLine($" First: \"{preview}\""); + } + } + catch (Exception ex) + { + Console.WriteLine($" ❌ Error: {ex.Message}"); + } + Console.WriteLine(); + + // Test 3: GetAssistantResponses + Console.WriteLine("Test 3: GetAssistantResponses()..."); + try + { + var assistantMsgs = ContentSummarizer.GetAssistantResponses(conv); + Console.WriteLine($" ✓ Found {assistantMsgs.Count} assistant responses"); + if (assistantMsgs.Count > 0) + { + var first = assistantMsgs.First(); + var preview = first.Length > 50 ? first.Substring(0, 50) + "..." : first; + Console.WriteLine($" First: \"{preview}\""); + } + } + catch (Exception ex) + { + Console.WriteLine($" ❌ Error: {ex.Message}"); + } + Console.WriteLine(); + + // Test 4: GetToolCallsInvoked + Console.WriteLine("Test 4: GetToolCallsInvoked()..."); + try + { + var toolCalls = ContentSummarizer.GetToolCallsInvoked(conv); + Console.WriteLine($" ✓ Found {toolCalls.Count} tool calls"); + if (toolCalls.Count > 0) + { + var first = toolCalls.First(); + Console.WriteLine($" First: {first.toolName} ({first.toolCallId})"); + } + } + catch (Exception ex) + { + Console.WriteLine($" ❌ Error: {ex.Message}"); + } + Console.WriteLine(); + + // Test 5: GetActionSummary + Console.WriteLine("Test 5: GetActionSummary()..."); + try + { + var actions = ContentSummarizer.GetActionSummary(conv); + Console.WriteLine($" ✓ Found {actions.Count} actions"); + if (actions.Count > 0) + { + Console.WriteLine($" First: {actions.First()}"); + } + } + catch (Exception ex) + { + Console.WriteLine($" ❌ Error: {ex.Message}"); + } + Console.WriteLine(); + + // Test 6: Summarize + Console.WriteLine("Test 6: Summarize()..."); + try + { + var summary = ContentSummarizer.Summarize(conv); + Console.WriteLine($" ✓ Summary: \"{summary}\""); + } + catch (Exception ex) + { + Console.WriteLine($" ❌ Error: {ex.Message}"); + } + Console.WriteLine(); + + // Test 7: ExtractTitle + Console.WriteLine("Test 7: ExtractTitle()..."); + try + { + var title = ContentSummarizer.ExtractTitle(conv); + Console.WriteLine($" ✓ Title: \"{title}\""); + } + catch (Exception ex) + { + Console.WriteLine($" ❌ Error: {ex.Message}"); + } + Console.WriteLine(); + + // Test 8: GetMessageCounts + Console.WriteLine("Test 8: GetMessageCounts()..."); + try + { + var counts = ContentSummarizer.GetMessageCounts(conv); + Console.WriteLine($" ✓ Counts: user={counts.user}, assistant={counts.assistant}, tool={counts.tool}, system={counts.system}"); + } + catch (Exception ex) + { + Console.WriteLine($" ❌ Error: {ex.Message}"); + } + Console.WriteLine(); + + // Test 9: GetToolCallStatistics + Console.WriteLine("Test 9: GetToolCallStatistics()..."); + try + { + var stats = ContentSummarizer.GetToolCallStatistics(conv); + Console.WriteLine($" ✓ Tool usage statistics:"); + foreach (var stat in stats.Take(5)) + { + Console.WriteLine($" {stat.Key}: {stat.Value}"); + } + } + catch (Exception ex) + { + Console.WriteLine($" ❌ Error: {ex.Message}"); + } + Console.WriteLine(); + + // Test 10: Null safety + Console.WriteLine("Test 10: Null safety..."); + try + { + ContentSummarizer.GetUserMessages(null!); + Console.WriteLine(" ❌ Should have thrown ArgumentNullException"); + } + catch (ArgumentNullException) + { + Console.WriteLine(" ✓ Correctly throws ArgumentNullException for null conversation"); + } + catch (Exception ex) + { + Console.WriteLine($" ❌ Wrong exception type: {ex.GetType().Name}"); + } + + Console.WriteLine("\n=== All Tests Complete ==="); + } +} diff --git a/src/cycodj/assets/help/branches.txt b/src/cycodj/assets/help/branches.txt new file mode 100644 index 00000000..b270e10b --- /dev/null +++ b/src/cycodj/assets/help/branches.txt @@ -0,0 +1,69 @@ +COMMAND: cycodj branches + +DESCRIPTION: + Displays conversation branching relationships in a tree structure. + + Analyzes chat history files to detect when conversations branch from each other + (when you continue from an earlier point in a conversation). Shows the parent-child + relationships in an easy-to-read tree format. + +USAGE: + cycodj branches [options] + +OPTIONS: + --date, -d Filter conversations by date (YYYY-MM-DD) + --last Limit to last N conversations or time period (e.g., "7d", "2h") + --messages Number of messages to show per conversation (default: 0) + --stats Add statistics summary at end of output + --save-output Save output to file instead of console + --conversation, -c Show branches for a specific conversation + --verbose, -v Show detailed branching information + --debug Enable debug output + --quiet Suppress non-essential output + +EXAMPLES: + # Show all conversation branches + cycodj branches + + # Show branches for a specific date + cycodj branches --date 2024-12-20 + + # Show last 10 conversations with message previews + cycodj branches --last 10 --messages 3 + + # Show last week's branches with statistics + cycodj branches --last 7d --stats + + # Show branches with detailed information + cycodj branches --verbose --messages 2 + + # Show branches for a specific conversation + cycodj branches --conversation chat-history-1754437373970 + + # Save branch tree to file + cycodj branches --last 20 --messages 1 --save-output tree.md + +OUTPUT: + The tree view shows: + 📁 - Root conversations (no parent) + ├─ - Branched conversations (has a parent) + + Example output: + 📁 2024-12-20 10:15:33 - Installing Git CLI + ├─ 2024-12-20 10:22:15 - Trying alternative approach + ├─ 2024-12-20 10:25:30 - Using chocolatey instead + ├─ 2024-12-20 10:28:00 - Added configuration + + Conversations that share the same tool_call_id sequence at the beginning + are identified as branches. The more tool_call_ids they share, the later + in the conversation the branch point occurred. + +VERBOSE MODE: + With --verbose, shows additional details: + - Number of messages (user, assistant) + - Number of tool calls + - Exact branch point (which tool call diverged) + +SEE ALSO: + cycodj list - List conversations + cycodj help - General help diff --git a/src/cycodj/assets/help/cleanup.txt b/src/cycodj/assets/help/cleanup.txt new file mode 100644 index 00000000..6ec0486e --- /dev/null +++ b/src/cycodj/assets/help/cleanup.txt @@ -0,0 +1,131 @@ +CLEANUP COMMAND - Clean up conversation history + +USAGE: + cycodj cleanup [options] + +OPTIONS: + --find-duplicates Find duplicate conversation files + --remove-duplicates Remove duplicate files (requires --execute) + --find-empty Find empty or invalid conversation files + --remove-empty Remove empty files (requires --execute) + --older-than-days Only consider files older than N days + --execute Actually perform removal (default is dry-run) + +COMMON OPTIONS: + --instructions Apply AI processing to cleanup results + --use-built-in-functions Enable AI to use tools + --save-chat-history Save AI processing chat to file + +EXAMPLES: + cycodj cleanup --find-duplicates + Find duplicate conversation files (dry-run) + + cycodj cleanup --find-empty + Find empty or invalid conversation files (dry-run) + + cycodj cleanup --remove-duplicates --execute + Remove duplicate files (actually deletes them) + + cycodj cleanup --remove-empty --execute + Remove empty files (actually deletes them) + + cycodj cleanup --find-duplicates --older-than-days 30 + Find duplicates older than 30 days + + cycodj cleanup --remove-empty --older-than-days 90 --execute + Remove empty files older than 90 days + + cycodj cleanup --find-duplicates --find-empty + Find both duplicates and empty files + + cycodj cleanup --remove-duplicates --remove-empty --execute + Remove both duplicates and empty files + +DESCRIPTION: + The cleanup command helps maintain your chat history by identifying and + removing problematic files. It can detect: + + - Duplicate conversations (same content, different filenames) + - Empty conversation files (no messages) + - Invalid conversation files (corrupted or malformed JSON) + + By default, cleanup runs in DRY-RUN mode, showing what would be removed + without actually deleting anything. Use --execute to perform actual + deletions. + +SAFETY FEATURES: + - Dry-run by default (--execute required for actual deletion) + - Clear indication of what will be removed + - File count and size summaries before removal + - Preserves at least one copy of duplicate conversations + +DUPLICATE DETECTION: + Duplicates are identified by: + - Conversation ID matching + - Message content matching + - Timestamp proximity + + When duplicates are found, the cleanup command: + - Keeps the newest file by default + - Reports all duplicates found + - Shows space that would be saved + +EMPTY FILE DETECTION: + Empty files include: + - Files with no messages + - Files with only system messages + - Malformed or corrupted JSON files + - Files that can't be parsed + +AGE FILTERING: + Use --older-than-days to limit cleanup to old files: + --older-than-days 30 (files older than 30 days) + --older-than-days 90 (files older than 90 days) + --older-than-days 365 (files older than 1 year) + + This is useful for: + - Archiving old conversations + - Cleaning up test data + - Removing stale drafts + +WORKFLOW: + 1. Run cleanup with --find-* flags (dry-run) + 2. Review the list of files to be removed + 3. Run again with --remove-* and --execute to delete + + Example workflow: + # Step 1: See what would be removed + cycodj cleanup --find-duplicates --find-empty + + # Step 2: Review the output carefully + + # Step 3: Actually remove the files + cycodj cleanup --remove-duplicates --remove-empty --execute + +OUTPUT: + Dry-run mode shows: + - List of files that would be removed + - Reason for removal (duplicate, empty, invalid) + - Total files and space to be freed + + Execute mode shows: + - Files being removed + - Success/failure for each deletion + - Final summary of removed files + +WARNING: + - Deleted files cannot be recovered + - Always run dry-run first (default behavior) + - Consider backing up your history directory before cleanup: + ~/.cycod/history/ + +AI PROCESSING: + Apply AI analysis to cleanup results: + cycodj cleanup --find-duplicates --instructions "recommend cleanup strategy" + cycodj cleanup --find-empty --instructions "analyze why files are empty" + +SEE ALSO + + cycodj help list + cycodj help stats + cycodj help examples diff --git a/src/cycodj/assets/help/examples.txt b/src/cycodj/assets/help/examples.txt new file mode 100644 index 00000000..ddbeae5c --- /dev/null +++ b/src/cycodj/assets/help/examples.txt @@ -0,0 +1,157 @@ +EXAMPLES - Common usage patterns for cycodj + +LISTING CONVERSATIONS + + EXAMPLE 1: List recent conversations (default behavior) + + cycodj + cycodj list + cycodj list --last 20 + + EXAMPLE 2: List more or fewer conversations + + cycodj list --last 5 + cycodj list --last 100 + + EXAMPLE 3: List conversations from a specific date + + cycodj list --date 2025-12-20 + cycodj list --date today + cycodj list --date 2025-12-20 --last 10 + +SEARCHING CONVERSATIONS + + EXAMPLE 4: Basic text search + + cycodj search "authentication" + cycodj search "how do I" + cycodj search "TODO" + + EXAMPLE 5: Advanced search with filters + + cycodj search "JWT" --user-only + cycodj search "error" --assistant-only + cycodj search "implementation" --date today + + EXAMPLE 6: Regular expression search + + cycodj search --regex "JWT|OAuth|SAML" + cycodj search --regex "TODO|FIXME|HACK" --case-sensitive + + EXAMPLE 7: Search with context + + cycodj search "bug" --context 5 + cycodj search "performance" -C 10 --date 2025-12-20 + +VIEWING CONVERSATION DETAILS + + EXAMPLE 8: Show a specific conversation + + cycodj show 1754437373970 + cycodj show chat-history-1754437373970 + + EXAMPLE 9: Show conversation with tool details + + cycodj show 1754437373970 --show-tool-calls + cycodj show 1754437373970 --show-tool-output + cycodj show 1754437373970 --max-content-length 2000 + +VISUALIZING CONVERSATION BRANCHES + + EXAMPLE 10: Show conversation tree + + cycodj branches + cycodj branches --date today + cycodj branches --last 20 + + EXAMPLE 11: Show branches with details and messages + + cycodj branches --verbose + cycodj branches --last 10 --messages 3 + cycodj branches --last 7d --messages 2 --stats + +USING COMPOSABLE FLAGS + + EXAMPLE 12: Control message display + + cycodj list --messages 1 # Minimal preview + cycodj list --messages 5 # More context + cycodj list --messages all # Show everything + cycodj search "bug" --messages 10 # Detailed search results + + EXAMPLE 13: Show branch relationships + + cycodj list --branches # See which convs are branches + cycodj list --branches --messages 1 # Compact branch view + cycodj search "TODO" --branches # Search with branch indicators + + EXAMPLE 14: Add statistics to output + + cycodj list --stats # List with stats + cycodj search "error" --stats # Search with stats + cycodj branches --stats # Tree with stats + cycodj list --last 7d --messages 5 --branches --stats # Everything! + + EXAMPLE 15: Save output to files + + cycodj list --save-output recent.md + cycodj list --last 7d --stats --save-output weekly-report.md + cycodj search "TODO" --branches --save-output todos.md + cycodj branches --messages 2 --save-output tree.md + +VIEWING STATISTICS + + EXAMPLE 16: Basic statistics + + cycodj stats + cycodj stats --date today + cycodj stats --last 7d + + EXAMPLE 17: Detailed statistics + + cycodj stats --show-tools + cycodj stats --last 100 --show-tools --save-output report.md + +CLEANING UP HISTORY + + EXAMPLE 18: Find issues (dry run) + + cycodj cleanup --find-duplicates + cycodj cleanup --find-empty + + EXAMPLE 19: Remove duplicates and empty files + + cycodj cleanup --remove-duplicates --execute + cycodj cleanup --remove-empty --execute + + EXAMPLE 20: Clean up old conversations + + cycodj cleanup --older-than-days 90 --remove-empty --execute + +COMBINING WITH AI PROCESSING + + EXAMPLE 21: Apply AI instructions to output + + cycodj list --instructions "summarize the main topics" + cycodj search "performance" --instructions "categorize by issue type" + cycodj stats --instructions "identify usage patterns" + + EXAMPLE 22: Use built-in functions with AI + + cycodj stats --instructions "create a visualization plan" --use-built-in-functions + cycodj list --last 30 --instructions "organize by project" --save-output report.md + + EXAMPLE 23: Save chat history of AI processing + + cycodj list --instructions "summarize" --save-chat-history analysis.jsonl + cycodj search "TODO" --instructions "prioritize" --save-chat-history todos.jsonl + +SEE ALSO + + cycodj help + cycodj help list + cycodj help search + cycodj help show + cycodj help branches + cycodj help stats + cycodj help cleanup diff --git a/src/cycodj/assets/help/help.txt b/src/cycodj/assets/help/help.txt new file mode 100644 index 00000000..d4f03f05 --- /dev/null +++ b/src/cycodj/assets/help/help.txt @@ -0,0 +1,105 @@ +CYCODJ - Chat History Journal and Analysis Tool + +DESCRIPTION: + cycodj analyzes and journals your cycod chat history files stored in + the ~/.cycod/history/ directory. It provides powerful tools for browsing, + searching, analyzing, and exporting your AI conversation history. + +MAIN COMMANDS: + list - List conversations with previews and filters + show - Display detailed conversation information + search - Search across conversation content + branches - Visualize conversation branching tree + stats - View usage statistics and patterns + cleanup - Clean up duplicate or empty conversations + help - Show help information + version - Show version information + +COMMON WORKFLOWS: + + Browse Recent Conversations: + cycodj # List last 20 conversations + cycodj list --last 50 # List last 50 conversations + cycodj list --date today # List today's conversations + + Find Specific Content: + cycodj search "authentication" # Search all conversations + cycodj search "TODO" --date today # Search today's conversations + cycodj search --regex "bug|fix" # Use regex patterns + + Review Conversation Details: + cycodj show # Show full conversation + cycodj show --show-tool-calls # Include tool details + + Understand Conversation Flow: + cycodj branches # Show branching tree + cycodj branches --verbose # Show detailed branch info + + Generate Reports: + cycodj list --stats # List with statistics + cycodj stats # Usage statistics + cycodj stats --show-tools # Tool usage breakdown + cycodj list --save-output report.md # Save to file + + Maintain History: + cycodj cleanup --find-duplicates # Find duplicates + cycodj cleanup --remove-empty --execute # Clean empty files + +COMMON OPTIONS: + --date Filter by date (YYYY-MM-DD or "today") + --last Limit to last N conversations or time period (e.g., "7d", "2h") + --messages Number of messages to show per conversation + --stats Add statistics summary to output + --branches Show branch indicators (↳) on list/search + --save-output Save output to file instead of console + --verbose, -v Show detailed output + --debug Enable debug output + --quiet Suppress non-essential output + --log Log to file + +AI PROCESSING OPTIONS: + --instructions Apply AI processing to output + --use-built-in-functions Enable AI to use tools + --save-chat-history Save AI processing chat + +GETTING HELP: + cycodj help # General help + cycodj help # Command-specific help + cycodj help examples # Usage examples + cycodj help options # All options explained + +DETAILED COMMAND HELP: + cycodj help list # List command details + cycodj help show # Show command details + cycodj help search # Search command details + cycodj help branches # Branches command details + cycodj help stats # Stats command details + cycodj help cleanup # Cleanup command details + +KEY FEATURES: + - Browse conversation history with rich previews + - Control message display count (--messages N or --messages all) + - Search across all messages with regex support + - Visualize conversation branching (when you tried different approaches) + - Show branch indicators inline with lists (--branches) + - Add statistics to any output (--stats) + - Save any output to markdown files (--save-output) + - View statistics on tool usage and patterns + - Clean up duplicates and empty files + - Apply AI processing to any output + - Filter by date ranges and conversation count + +UNDERSTANDING BRANCHES: + When you branch a conversation (continue from an earlier point), cycodj + tracks these relationships. The branches command visualizes this as a + tree, helping you understand how conversations evolved. + +HISTORY LOCATION: + Conversations are stored in: ~/.cycod/history/ + Each conversation is a JSONL file with messages and metadata + +SEE ALSO: + cycodj help examples # Comprehensive examples + cycodj help options # All options explained + cycod help # Main cycod tool help + diff --git a/src/cycodj/assets/help/list.txt b/src/cycodj/assets/help/list.txt new file mode 100644 index 00000000..5f2a3b12 --- /dev/null +++ b/src/cycodj/assets/help/list.txt @@ -0,0 +1,67 @@ +LIST COMMAND - List chat history conversations + +USAGE: + cycodj list [options] + +OPTIONS: + --date Filter conversations by date (YYYY-MM-DD, or "today"/"yesterday") + --last Limit to last N conversations or time period (e.g., "7d", "2h") + --messages Number of messages to show per conversation (default: 3) + --branches Show branch indicators (↳) and relationships + --stats Add statistics summary at end of output + --save-output Save output to file instead of console + +TIME FILTER OPTIONS: + --today Show today's conversations + --yesterday Show yesterday's conversations + --after Show conversations after time (e.g., "2025-12-20" or "-7d") + --before Show conversations before time + --date-range Show conversations in time range (e.g., "-7d.." or "2025-12-01..2025-12-31") + +EXAMPLES: + cycodj list + List last 20 conversations with 3 message previews each (default) + + cycodj list --last 50 + Show the 50 most recent conversations + + cycodj list --last 7d + Show conversations from the last 7 days + + cycodj list --messages 1 + List conversations with minimal message preview + + cycodj list --messages all + List conversations showing all messages (like export) + + cycodj list --branches + Show conversations with branch indicators (↳) and indentation + + cycodj list --stats + Show conversation list plus statistics summary at end + + cycodj list --last 10 --messages 5 --branches --stats + Combine multiple flags: 10 conversations, 5 messages each, with branches and stats + + cycodj list --today --save-output today.md + Save today's conversations to a markdown file + + cycodj list --date 2025-12-20 + List all conversations from December 20, 2025 + +DESCRIPTION: + The list command displays conversations from your chat history directory + (~/.cycod/history/). Each conversation shows its timestamp, title (if available), + message counts by role, and message previews. + + By default, shows 3 messages per conversation and lists the last 20 conversations. + Use --messages to control preview length, --last to change conversation count, + and --branches to see how conversations relate to each other. + + All flags are composable - mix and match them to get exactly the view you need. + +SEE ALSO: + cycodj help search - Search within conversations + cycodj help show - View full conversation details + cycodj help branches - Visualize conversation trees + cycodj help stats - View statistics diff --git a/src/cycodj/assets/help/options.txt b/src/cycodj/assets/help/options.txt new file mode 100644 index 00000000..15e827a9 --- /dev/null +++ b/src/cycodj/assets/help/options.txt @@ -0,0 +1,321 @@ +OPTIONS - Common options available across cycodj commands + +TIME FILTERING OPTIONS + + --today + Shortcut for today's conversations (midnight to now) + Example: cycodj list --today + + --yesterday + Shortcut for yesterday's conversations (full day) + Example: cycodj list --yesterday + + --last + Smart filtering by count OR time period + + Conversation count (pure number): + --last 20 Last 20 conversations + --last 50 Last 50 conversations + + Time period (TIMESPEC format): + --last 7d Last 7 days + --last 30d Last 30 days + --last 4h Last 4 hours + --last yesterday Yesterday + --last today Today + + TIMESPEC is automatically detected! + + --after + Show conversations after specified time + Example: --after 7d, --after "2025-01-01", --after yesterday + + --before + Show conversations before specified time + Example: --before yesterday, --before "2025-12-20" + + --date-range .. + Show conversations in date range + Examples: + --date-range "7d..today" Last 7 days + --date-range "2025-01-01..2025-12-31" Full year + --date-range "3d.." Last 3 days to now + --date-range "..yesterday" Everything up to yesterday + + --date , -d + (Legacy) Filter conversations by specific date + Accepts: YYYY-MM-DD format or "today" + Example: --date 2025-12-20, --date today + Note: Use --last or --date-range for more flexibility + +TIMESPEC FORMATS + + Absolute dates: + "2023-09-01" + "September 1, 2023" + "2025-12-20" + + Relative times (ago): + 7d = 7 days ago + 4h = 4 hours ago + 30m = 30 minutes ago + 2d4h = 2 days 4 hours ago + + Keywords: + today + yesterday + + Ranges: + "2023-01-01..2023-12-31" Absolute range + "7d..today" Last 7 days to today + "3d.." Last 3 days to now + "..yesterday" Everything up to yesterday + +OTHER FILTERING OPTIONS + + --conversation , -c + Target a specific conversation by ID or filename + Example: --conversation 1754437373970 + Example: --conversation chat-history-1754437373970 + +DISPLAY OPTIONS + + --verbose, -v + Enable verbose output with additional details + Shows more information about branches, tool calls, etc. + + --debug + Enable debug output for troubleshooting + Shows internal processing details and errors + + --quiet + Suppress non-essential output + Only shows critical information and errors + + --log + Write log output to specified file + Useful for debugging and audit trails + +COMPOSABLE DISPLAY OPTIONS + + These options work on multiple commands (list, search, branches, show) + and can be combined together: + + --messages + Control how many messages to show per conversation + + Numbers: + --messages 1 Show 1 message (minimal) + --messages 3 Show 3 messages (default for list/search) + --messages 5 Show 5 messages (more context) + --messages 10 Show 10 messages + + Special: + --messages all Show all messages (like export) + + Defaults: + list: 3 messages + search: 3 messages + branches: 0 messages (structure only) + show: all messages + + Examples: + cycodj list --messages 1 + cycodj search "bug" --messages 10 + cycodj branches --messages 3 + + --branches + Show branch indicators (↳) and indentation + Available on: list, search + + Shows which conversations are branches of others, + with visual indicators and indentation to show relationships + + Examples: + cycodj list --branches + cycodj search "TODO" --branches + + --stats + Add statistics summary at end of output + Available on: list, search, branches, show, stats + + Appends a summary showing message counts, conversation counts, + branch statistics, etc. Does not replace the main output. + + Examples: + cycodj list --stats + cycodj search "error" --stats + cycodj branches --last 20 --stats + + --save-output + Save output to file instead of printing to console + Available on: list, search, branches, show, stats + + Examples: + cycodj list --save-output recent.md + cycodj stats --show-tools --save-output report.md + cycodj search "TODO" --save-output todos.md + + COMBINING FLAGS: + All these options work together! Examples: + + cycodj list --last 10 --messages 5 --branches --stats + cycodj search "bug" --last 7d --messages 1 --branches --save-output bugs.md + cycodj branches --last 20 --messages 2 --stats --save-output tree.md + +AI PROCESSING OPTIONS + + These options are available on most commands to apply AI processing + to the command's output: + + --instructions + Apply AI processing with the given instructions + The AI receives the command output and processes it according + to your instructions + + Examples: + --instructions "summarize the main topics" + --instructions "identify common themes" + --instructions "create a report" + --instructions @instructions.md (load from file) + + --use-built-in-functions + Allow the AI to use built-in tools during processing + Enables file operations, shell commands, etc. + Use with caution - the AI can modify files + + Example: + --instructions "analyze and save to file" --use-built-in-functions + + --save-chat-history + Save the AI processing conversation to a JSONL file + Useful for tracking AI analysis and decisions + + Example: + --save-chat-history analysis.jsonl + +COMMAND-SPECIFIC OPTIONS + + List Command: + --date, --last, --messages, --branches, --stats, --save-output + (see: cycodj help list) + + Show Command: + --show-tool-calls Show tool call details + --show-tool-output Show full tool output + --max-content-length Set message truncation length + --stats, --save-output + (see: cycodj help show) + + Search Command: + --case-sensitive, -c Case-sensitive search + --regex, -r Use regular expressions + --user-only, -u Search only user messages + --assistant-only, -a Search only assistant messages + --context , -C Context lines around matches + --messages, --branches, --stats, --save-output + (see: cycodj help search) + + Branches Command: + --date, --last, --conversation, --verbose + --messages, --stats, --save-output + (see: cycodj help branches) + + Stats Command: + --show-tools Show tool usage statistics + --show-dates Show per-date breakdown + --save-output Save output to file + (see: cycodj help stats) + + Cleanup Command: + --find-duplicates Find duplicate files + --remove-duplicates Remove duplicates + --find-empty Find empty files + --remove-empty Remove empty files + --older-than-days Age filter for cleanup + --execute Actually perform removal + (see: cycodj help cleanup) + +ENVIRONMENT VARIABLES + + CYCOD_DEBUG + Set to "1" or "true" to enable debug output + Equivalent to --debug flag + + CYCOD_VERBOSE + Set to "1" or "true" to enable verbose output + Equivalent to --verbose flag + + CYCOD_QUIET + Set to "1" or "true" to enable quiet mode + Equivalent to --quiet flag + +TIME FILTERING EXAMPLES + + Shortcuts (most common): + cycodj list --today # Today's conversations + cycodj list --yesterday # Yesterday's conversations + cycodj stats --today # Today's stats + + Smart --last with count: + cycodj list --last 20 # Last 20 conversations + cycodj stats --last 50 # Stats for last 50 + + Smart --last with time: + cycodj list --last 7d # Last 7 days + cycodj list --last 30d # Last 30 days + cycodj branches --last 7d # Week branches + cycodj search "bug" --last 2d # Search last 2 days + + Date ranges: + cycodj list --date-range "7d..today" # Last week + cycodj stats --date-range "2023-01-01..2023-12-31" # Year stats + cycodj list --date-range "3d.." # Last 3 days to now + + Explicit boundaries: + cycodj list --after 7d # After 7 days ago + cycodj list --before yesterday # Before yesterday + cycodj stats --after "2025-01-01" --before "2025-12-31" + + Combined with other options: + cycodj search "TODO" --last 7d --branches --save-output todos.md + cycodj list --yesterday --messages 5 --stats + cycodj branches --last 30d --messages 2 --stats + +USING OPTIONS WITH AI INSTRUCTIONS + + You can combine filtering and AI processing: + + cycodj list --date today --instructions "categorize by topic" + cycodj search "TODO" --instructions "prioritize" --save-chat-history todos.jsonl + cycodj stats --last 100 --instructions "identify trends" --use-built-in-functions + + The --instructions option is powerful for: + - Summarizing output + - Categorizing results + - Generating reports + - Extracting specific information + - Reformatting output + - Creating visualizations (with --use-built-in-functions) + +LOADING INSTRUCTIONS FROM FILES + + Prefix with @ to load instructions from a file: + --instructions @analyze.md + --instructions @/path/to/instructions.txt + + This is useful for: + - Reusing complex instructions + - Sharing analysis templates + - Version controlling your workflows + +SEE ALSO + + cycodj help + cycodj help examples + cycodj help list + cycodj help search + cycodj help show + cycodj help branches + cycodj help stats + cycodj help cleanup diff --git a/src/cycodj/assets/help/search.txt b/src/cycodj/assets/help/search.txt new file mode 100644 index 00000000..5edf6f7d --- /dev/null +++ b/src/cycodj/assets/help/search.txt @@ -0,0 +1,90 @@ +SEARCH COMMAND - Search across conversation content + +USAGE: + cycodj search [options] + +ARGUMENTS: + Text or regex pattern to search for + +OPTIONS: + --date Filter by date (YYYY-MM-DD or "today"/"yesterday") + --last Limit to last N conversations or time period (e.g., "7d", "2h") + --messages Number of messages to show per match (default: 3) + --branches Show branch indicators (↳) and relationships + --stats Add statistics summary at end of output + --save-output Save output to file instead of console + --case-sensitive, -c Enable case-sensitive search + --regex, -r Use regular expressions + --user-only, -u Search only user messages + --assistant-only, -a Search only assistant messages + --context , -C Show N lines of context (default: 2) + +COMMON OPTIONS: + --instructions Apply AI processing to search results + --use-built-in-functions Enable AI to use tools + --save-chat-history Save AI processing chat to file + +EXAMPLES: + cycodj search "authentication" + Search for "authentication" in all messages + + cycodj search "JWT" --user-only + Search for "JWT" only in user messages + + cycodj search --regex "TODO|FIXME|HACK" + Use regex to search for multiple patterns + + cycodj search "error" --date today --context 5 + Search today's conversations with 5 lines of context + + cycodj search "performance" --case-sensitive --last 50 + Case-sensitive search in last 50 conversations + + cycodj search "bug" --assistant-only --context 0 + Search assistant messages with no context lines + + cycodj search "TODO" --messages 1 --branches + Find TODOs showing branch relationships with minimal messages + + cycodj search "error" --last 7d --stats --save-output errors.md + Search last week's errors, show stats, and save to file + + cycodj search "authentication" --messages all + Show all messages in conversations containing "authentication" + +DESCRIPTION: + The search command finds text patterns across all conversations in your + chat history. It displays matching messages with configurable context + lines before and after each match. + + Search results show: + - Conversation timestamp and title + - File path to the conversation + - Number of matches in each conversation + - Message role (user/assistant/tool) for each match + - Matched lines with surrounding context + + By default, search is case-insensitive and searches all message types + except system messages. + +FILTERING: + Use --date to limit search to specific dates + Use --last to limit the number of conversations searched + Use --user-only or --assistant-only to filter by message role + Use --context 0 to show only matching lines without context + +PATTERN MATCHING: + Normal search uses substring matching (case-insensitive by default) + Use --regex to enable regular expression patterns + Use --case-sensitive for exact case matching + +AI PROCESSING: + Add --instructions to apply AI analysis to search results: + cycodj search "TODO" --instructions "categorize by priority" + cycodj search "error" --instructions "identify common root causes" + +SEE ALSO + + cycodj help list + cycodj help show + cycodj help examples diff --git a/src/cycodj/assets/help/show.txt b/src/cycodj/assets/help/show.txt new file mode 100644 index 00000000..fc979f63 --- /dev/null +++ b/src/cycodj/assets/help/show.txt @@ -0,0 +1,66 @@ +SHOW COMMAND - Display detailed conversation information + +USAGE: + cycodj show [options] + +ARGUMENTS: + The conversation ID (timestamp) or filename to display + Examples: 1754437373970, chat-history-1754437373970 + +OPTIONS: + --show-tool-calls Display details of tool calls made by the assistant + --show-tool-output Show full tool output without truncation + --max-content-length Maximum characters to display per message (default: 500) + --stats Add statistics summary at end of output + --save-output Save output to file instead of console + +EXAMPLES: + cycodj show 1754437373970 + Display conversation with ID 1754437373970 + + cycodj show chat-history-1754437373970 + Display conversation by full filename + + cycodj show 1754437373970 --show-tool-calls + Show conversation with tool call details + + cycodj show 1754437373970 --show-tool-output + Show conversation without truncating large tool outputs + + cycodj show 1754437373970 --max-content-length 1000 + Display up to 1000 characters per message + + cycodj show 1754437373970 --stats + Show conversation with statistics summary at end + + cycodj show 1754437373970 --save-output conversation.md + Save conversation to markdown file + +DESCRIPTION: + The show command displays detailed information about a specific conversation, + including: + + - Conversation metadata (timestamp, file path, title) + - Message counts by role (user, assistant, tool, system) + - Branch relationships (parent conversation, child branches) + - All messages with role-based color coding: + * User messages (green) + * Assistant messages (blue) + * Tool output (gray) + * System messages (magenta) + + Large tool outputs are truncated by default for readability. Use + --show-tool-output to see the complete output. + + System messages (prompts) are hidden by default. Use --verbose to display them. + +BRANCH INFORMATION: + If the conversation is a branch of another conversation, the show command + displays the parent conversation ID. + + If other conversations branch from this one, those child branches are listed. + +FINDING CONVERSATION IDS: + Use the 'list' command to find conversation IDs: + cycodj list + cycodj list --date today diff --git a/src/cycodj/assets/help/stats.txt b/src/cycodj/assets/help/stats.txt new file mode 100644 index 00000000..0a955822 --- /dev/null +++ b/src/cycodj/assets/help/stats.txt @@ -0,0 +1,107 @@ +STATS COMMAND - View chat history statistics + +USAGE: + cycodj stats [options] + +OPTIONS: + --date Filter by date (YYYY-MM-DD or "today"/"yesterday") + --last Limit to last N conversations or time period (e.g., "7d", "2h") + --show-tools Display tool usage statistics + --show-dates Show per-date breakdown (enabled by default) + --save-output Save output to file instead of console + +COMMON OPTIONS: + --instructions Apply AI processing to statistics + --use-built-in-functions Enable AI to use tools + --save-chat-history Save AI processing chat to file + +EXAMPLES: + cycodj stats + Show statistics for all conversations + + cycodj stats --date today + Statistics for today's conversations only + + cycodj stats --last 100 + Statistics for last 100 conversations + + cycodj stats --show-tools + Include detailed tool usage statistics + + cycodj stats --date 2025-12-20 --show-tools + Daily statistics with tool breakdown + + cycodj stats --last 50 --show-dates + Summary stats with per-date breakdown + + cycodj stats --last 7d --show-tools --save-output weekly-report.md + Save last week's statistics with tool usage to file + + cycodj stats --instructions "identify usage trends" + Generate statistics with AI analysis + +DESCRIPTION: + The stats command provides comprehensive statistics about your chat + conversations. It analyzes message counts, conversation patterns, + branching behavior, and tool usage. + +STATISTICS DISPLAYED: + + Overall Summary: + - Total number of conversations + - Total messages (by role: user, assistant, tool) + - Average messages per conversation + - Branch statistics (how many conversations are branches) + + Per-Date Breakdown (when --no-dates not specified): + - Conversations per day + - Messages per day + - Branch activity per day + + Tool Usage (when --show-tools enabled): + - Most frequently used tools + - Tool call counts + - Tool usage by conversation + - Tool usage trends over time + +FILTERING: + Use --date to analyze a specific date: + cycodj stats --date 2025-12-20 + cycodj stats --date today + + Use --last to limit analysis scope: + cycodj stats --last 30 + + Combine filters for focused analysis: + cycodj stats --date 2025-12-20 --show-tools + +OUTPUT OPTIONS: + Default view includes per-date breakdown + Use --no-dates for summary-only view + Use --show-tools for detailed tool statistics + +AI ANALYSIS: + Apply AI processing to interpret statistics: + cycodj stats --instructions "identify productivity patterns" + cycodj stats --instructions "suggest areas for improvement" + cycodj stats --show-tools --instructions "analyze tool effectiveness" + +USE CASES: + - Track conversation volume over time + - Identify peak usage periods + - Analyze conversation branching patterns + - Monitor tool usage patterns + - Generate usage reports + - Understand AI assistant interaction patterns + +INTERPRETING RESULTS: + High branch counts indicate iterative problem-solving + Tool usage patterns reveal automation effectiveness + Message counts show interaction complexity + Date breakdowns help identify usage trends + +SEE ALSO + + cycodj help list + cycodj help branches + cycodj help examples diff --git a/src/cycodj/assets/help/usage.txt b/src/cycodj/assets/help/usage.txt new file mode 100644 index 00000000..3897d656 --- /dev/null +++ b/src/cycodj/assets/help/usage.txt @@ -0,0 +1,81 @@ +Welcome to CYCODJ, the Chat History Journal and Analysis Tool! + +Using CYCODJ, you can: + + - List and browse your chat conversations + - Search across conversation content + - Display detailed conversation information + - Visualize conversation branching trees + - View usage statistics and tool breakdowns + - Save any output to markdown files + - Clean up duplicate or empty conversations + +USAGE: cycodj [command] [options] + OR: cycodj list [options] + OR: cycodj show [options] + OR: cycodj search [options] + OR: cycodj branches [options] + OR: cycodj stats [options] + OR: cycodj cleanup [options] + +COMMANDS + + cycodj [...] (aka: cycodj list) + cycodj list [...] (see: cycodj help list) + cycodj show [...] (see: cycodj help show) + cycodj search [...] (see: cycodj help search) + cycodj branches [...] (see: cycodj help branches) + cycodj stats [...] (see: cycodj help stats) + cycodj cleanup [...] (see: cycodj help cleanup) + +EXAMPLES + + EXAMPLE 1: List recent conversations + + cycodj list + cycodj list --last 50 + cycodj list --date 2025-12-20 + + EXAMPLE 2: Search for specific content + + cycodj search "authentication" + cycodj search --regex "JWT|OAuth" --user-only + cycodj search "TODO" --date today + + EXAMPLE 3: Show conversation details + + cycodj show 1754437373970 + cycodj show chat-history-1754437373970 --show-tool-calls + + EXAMPLE 4: Visualize conversation branches + + cycodj branches + cycodj branches --date today --verbose + + EXAMPLE 5: Generate reports and save output + + cycodj stats + cycodj stats --show-tools + cycodj list --last 7d --stats --save-output weekly-report.md + + EXAMPLE 6: Use composable flags + + cycodj list --last 10 --messages 5 --branches --stats + cycodj search "bug" --last 7d --messages 1 --save-output bugs.md + cycodj branches --last 20 --messages 2 --stats + + EXAMPLE 7: Clean up conversation history + + cycodj cleanup --find-duplicates + cycodj cleanup --remove-empty --execute + +SEE ALSO + + cycodj help + cycodj help examples + cycodj help list + cycodj help search + cycodj help show + cycodj help branches + cycodj help stats + cycodj help cleanup diff --git a/src/cycodj/assets/prompts/system.md b/src/cycodj/assets/prompts/system.md new file mode 100644 index 00000000..0dbfb02b --- /dev/null +++ b/src/cycodj/assets/prompts/system.md @@ -0,0 +1,5 @@ +You are a helpful AI assistant specialized in analyzing chat history journals. + +You will be provided with a journal of chat conversations, and a set of instructions for how to process or summarize it. + +Your task is to apply the instructions to the journal content and return the result. diff --git a/src/cycodj/assets/prompts/user.md b/src/cycodj/assets/prompts/user.md new file mode 100644 index 00000000..6f515665 --- /dev/null +++ b/src/cycodj/assets/prompts/user.md @@ -0,0 +1,13 @@ +Chat History Journal: + +{backticks} +{@{contentFile}} +{backticks} + +Instructions: + +{backticks} +{@{instructionsFile}} +{backticks} + +Result after applying instructions (do not enclose in backticks): diff --git a/src/cycodj/cycodj.csproj b/src/cycodj/cycodj.csproj new file mode 100644 index 00000000..e8253af4 --- /dev/null +++ b/src/cycodj/cycodj.csproj @@ -0,0 +1,49 @@ + + + + + + net9.0 + enable + enable + true + cycodj + + Exe + + + win-x64;linux-x64;osx-x64 + + + CycoDj + Rob Chambers + Chat history journal and analysis CLI tool - dig through your cycod conversation history + cli;chat-history;journal;analysis;cycod;conversation + https://github.com/robch/cycod + MIT + README.md + + + true + cycodj + false + + + + + + + + + + + + + help\%(RecursiveDir)%(Filename)%(Extension) + + + prompts\%(RecursiveDir)%(Filename)%(Extension) + + + + diff --git a/todo/cycodj-branch-context.md b/todo/cycodj-branch-context.md new file mode 100644 index 00000000..e5c72f79 --- /dev/null +++ b/todo/cycodj-branch-context.md @@ -0,0 +1,235 @@ +# TODO: cycodj - Branch Context (Why Did This Branch?) + +## The Pain 😫 + +**Current State:** +The `branches` command shows me the tree structure beautifully: + +``` +📁 08:27 - Chat History Journal Tool + ├─ 08:56 - Chat History Journal Tool + ├─ 09:10 - Chat History Journal Tool + ├─ 09:22 - Chat History Journal Tool + └─ 09:34 - Chat History Journal Tool +``` + +**But I can't tell WHY they branched!** + +All I see is: +- Timestamp +- Title (often same for branches) +- That it branched + +**The Problems:** +- **Branches look identical:** "Chat History Journal Tool" repeated 5 times - which is which? +- **No context:** Why did I branch at 08:56? What was I trying differently? +- **Have to investigate:** Must open each conversation to understand +- **Pattern invisible:** Can't see "Phase 0 → Phase 1 → Phase 2" progression +- **Story lost:** The branching tells a story but I can't read it + +**Real-world frustration:** +Today analyzing your weekend, I saw: +``` +📁 09:45 - CDR Project Restart Instructions + ├─ 09:46 - CDR Project Restart Instructions (iteration 1) + ├─ 09:58 - CDR Project Restart Instructions (iteration 2) + ├─ 10:01 - CDR Project Restart Instructions (iteration 3) +``` + +12 branches deep! But WHY? What changed each time? + +I had to: +1. Note the conversation IDs +2. Use `show` on each one +3. Read the first user message +4. Manually reconstruct the story + +**It took 15 minutes to understand a tree that should explain itself.** + +--- + +## The Cure 💊 + +**What I Want:** +Show me WHY each branch exists - give me context: + +```bash +cycodj branches --date 2025-12-20 --with-context +``` + +**Output:** +``` +📁 08:27 - Chat History Journal Tool (73 msgs) + > "can you make a new worktree for cycodj..." + + ├─ 08:56 - Phase 0: Setup (79 msgs) + │ > "research cycodgr first..." + │ Branched: Added research phase + │ + ├─ 09:10 - Phase 1: Implementation (161 msgs) + │ > "now implement the list command..." + │ Branched: Starting implementation + │ + ├─ 09:22 - Phase 2: Search Feature (152 msgs) + │ > "add search capability..." + │ Branched: New feature branch + │ + └─ 09:34 - Phase 3: Testing (131 msgs) + > "let's test what we built..." + Branched: Moving to testing phase +``` + +**What Changed:** +- First user message shown (truncated) +- Can instantly see what each branch is about +- Branch reason/purpose visible +- Story readable without opening conversations + +--- + +## User Stories + +### Story 1: Understand Decision Points +**As a user,** I want to see why I branched a conversation +**So that** I can understand my decision-making process +**Currently:** Branch tree shows THAT I branched but not WHY +**Desired:** Branch tree shows first message explaining the branch purpose + +### Story 2: Quick Pattern Recognition +**As a user,** I want to scan branch contexts without opening each conversation +**So that** I can quickly understand what happened +**Currently:** Must `show` each conversation ID individually (slow) +**Desired:** Branch context visible inline in tree (fast) + +### Story 3: Debugging Workflow +**As a user,** I want to see the evolution of problem-solving +**So that** I can learn from what worked/didn't work +**Currently:** 10 branches that all say "Fix Bug" - can't tell them apart +**Desired:** Can see "Try approach A" → "That failed, try B" → "B works!" + +### Story 4: Documentation +**As a user,** I want to document a project's evolution +**So that** others can understand the journey +**Currently:** Have to manually piece together the story +**Desired:** Export branch tree with context = instant timeline + +--- + +## Success Criteria + +**This is solved when:** + +1. ✅ `branches` command shows first user message for each conversation (truncated) +2. ✅ Can see why each branch exists without opening it +3. ✅ Branch tree is readable as a narrative +4. ✅ Can optionally show more context (not just first message) +5. ✅ Works with `--verbose` to show even more detail + +**Bonus points if:** +- Shows branch "type" (exploration, bug fix, iteration, feature) +- Highlights branches that succeeded vs. failed +- Shows how many messages before branching (context depth) +- Can filter to show only certain types of branches + +--- + +## What Makes a Good Context? + +**Minimum (Default):** +- First user message (truncated to ~80 chars) +- Enough to understand intent + +**Better (`--verbose`):** +- First user message (full text) +- Message count +- Duration +- Whether branch led to more branches + +**Best (`--detailed`):** +- First user message +- Last message (outcome) +- Key decisions made +- Links to artifacts created + +--- + +## The Value + +**Understanding:** +- Current: 15 minutes to understand a complex branch tree +- Future: 30 seconds to scan and understand +- **30x faster comprehension** + +**Documentation:** +- Branch tree with context = instant project timeline +- Can export to markdown = shareable story +- Others can understand your workflow + +**Learning:** +- See what approaches worked vs. failed +- Understand iteration patterns +- Improve future workflows + +--- + +## Example Usage (Dream State) + +```bash +# Basic: Show first message for context +cycodj branches --date 2025-12-20 --with-context + +# Verbose: Show more details +cycodj branches --date 2025-12-21 --verbose --with-context + +# Filter: Only show branches that led to more branches (complex problems) +cycodj branches --date 2025-12-14 --deep-only + +# Export: Create a markdown timeline with context +cycodj export -o timeline.md --date 2025-12-20 --branch-context + +# Search: Find branches about specific topic +cycodj branches --contains "cycodgr" --with-context +``` + +--- + +## Real Example + +**What I saw today:** +``` +📁 06:45 - Implement Cycodgr AI Task (359 messages) + ├─ 07:03 - Implement Cycodgr AI Task (440 messages) +``` + +**What I wanted to see:** +``` +📁 06:45 - Implement Cycodgr AI Task (359 msgs) + > "Read todo/implement-cycodgr-ai.md and begin..." + Initial implementation attempt + + ├─ 07:03 - Implement Cycodgr AI Task (440 msgs) + > "trainer of tigers... let's try different approach..." + Branched: Previous approach hit limits, trying new strategy + Result: Led to successful implementation ✅ +``` + +**The difference:** +- Before: Mystery branch +- After: Clear story + +--- + +## Why This Matters + +**Branches are gold.** They show: +- Decision points +- Iteration patterns +- Problem-solving approaches +- What worked vs. didn't + +**But only if I can see what they're about!** + +Without context, branches are just cryptic tree structure. +With context, branches tell the story of how work evolved. + +**Make the invisible visible.** 🔍 diff --git a/todo/cycodj-large-output-handling.md b/todo/cycodj-large-output-handling.md new file mode 100644 index 00000000..7df0ef39 --- /dev/null +++ b/todo/cycodj-large-output-handling.md @@ -0,0 +1,316 @@ +# TODO: cycodj - Better Handling of Large Outputs + +## The Pain 😫 + +**Current State:** +When analyzing longer time periods, outputs get massive and unusable: + +**What I tried today:** +```bash +cycodj journal --last-days 9 +``` + +**What happened:** +``` +Output: [100,000 character limit reached] +... truncated ... +[59,155 lines remaining] +``` + +**The Problems:** +- **Truncation:** Lost most of the journal mid-week +- **No warning:** Tool didn't tell me output would be too large +- **No options:** Can't ask for summary-only or paginated output +- **Export also huge:** Exporting 9 days creates 1.5MB markdown file +- **Unusable for long periods:** Can't analyze a month or year +- **Terminal overload:** Huge outputs freeze/crash terminal + +**Interesting discovery:** Different commands naturally have different detail levels! +- `list` shows minimal preview (1 message) +- `journal` shows summary preview (3 messages) +- `show` shows full detail (all messages) +- `stats` shows just numbers +- `branches` shows structure only + +**But there's no way to control this across commands!** + +**Real-world frustration:** +Wanted to analyze Dec 14-22 (9 days): +- Journal output hit my 100K char limit +- Couldn't see most days +- Had to do day-by-day queries instead +- Lost the "big picture" view + +**For a month? Forget it.** Would be 300K+ characters. + +--- + +## The Cure 💊 + +**What I Want:** +Smart handling of large outputs with multiple options: + +### Option 1: Summary Mode +```bash +cycodj journal --last-days 30 --summary +``` + +**Output:** +``` +Month Summary: December 2025 + +Week 1 (Dec 1-7): + 45 conversations | 5,200 messages | 8 branches + Top topics: Testing framework (15 convs), Documentation (12 convs) + +Week 2 (Dec 8-14): + 147 conversations | 18,000 messages | 30 branches + Top topics: cycodgr implementation (50 convs), Debugging (35 convs) + +Week 3 (Dec 15-21): + 450 conversations | 30,000 messages | 90 branches + Top topics: Book summaries (100 convs), CDR project (240 convs) + +Week 4 (Dec 22-28): + 50 conversations | 6,000 messages | 10 branches + Top topics: cycodj development (20 convs), Analysis (15 convs) + +Total: 692 conversations, 59,200 messages +Most productive week: Week 3 (450 conversations) +Most complex week: Week 2 (30 branches) +``` + +**Benefit:** See the whole month in one screen + +### Option 2: Pagination +```bash +cycodj journal --last-days 30 --page 1 --page-size 7 +``` + +**Output:** +``` +Showing days 1-7 of 30 (Page 1 of 5) + +[First week's data] + +Use --page 2 to see next 7 days +``` + +**Benefit:** Process large ranges in chunks + +### Option 3: Progressive Detail +```bash +cycodj journal --last-days 30 --detail low +cycodj journal --date 2025-12-21 --detail high +``` + +**Benefit:** Overview first, drill down when needed + +### Option 4: Smart Truncation +```bash +cycodj journal --last-days 30 --max-chars 50000 +``` + +**Output:** +``` +WARNING: Output would be 300,000 chars, truncating to 50,000 + +[Summary of all days] +[Full detail for most recent days] +[Progressively less detail for older days] + +Run with --page or --summary for better large-range analysis +``` + +**Benefit:** Always get something usable, with helpful guidance + +--- + +## User Stories + +### Story 1: Monthly Review +**As a user,** I want to see my entire month's work without output truncation +**So that** I can understand monthly patterns and productivity +**Currently:** Journal for > 10 days gets truncated, unusable +**Desired:** Summary mode shows month in one screen + +### Story 2: Year-End Review +**As a user,** I want to analyze my entire year of work +**So that** I can write annual reviews and understand long-term trends +**Currently:** Impossible - even a month truncates +**Desired:** Can get yearly summary: `cycodj stats --year 2025 --summary` + +### Story 3: Progressive Exploration +**As a user,** I want to start with high-level overview then drill into specific days +**So that** I can find interesting patterns without data overload +**Currently:** Either see everything (too much) or specific day (too narrow) +**Desired:** See month summary → Week summary → Day detail (progressive disclosure) + +### Story 4: Export Control +**As a user,** I want to export without creating huge files +**So that** I can share reports without sending megabytes of markdown +**Currently:** Export creates 1.5MB file for 9 days +**Desired:** Export with `--summary-only` flag for compact reports + +--- + +## Success Criteria + +**This is solved when:** + +1. ✅ Can analyze month/year without truncation +2. ✅ Multiple output detail levels (summary, normal, verbose) +3. ✅ Warnings when output would be too large +4. ✅ Helpful suggestions for better commands +5. ✅ Progressive detail (summary → specific) + +**Bonus points if:** +- Automatic pagination for large outputs +- Streaming output (shows as it processes) +- Export size estimates before generating +- Smart defaults based on date range size +- Compression options for archives + +--- + +## Detail Level Examples + +**Note:** Commands already have implicit detail levels - we just need to formalize and make them controllable! + +### Current Natural Detail Levels: +- `list` = minimal (1 message preview, basic counts) +- `journal` = summary (3 message previews, time grouping) +- `show` = full (all messages, all details) +- `stats` = compressed (just numbers) +- `branches` = structural (tree only, no content) + +### Proposed `--detail` Flag (works across all commands): + +### `--detail minimal` +``` +Dec 21: 240 convs, CDR project (77%), 6,858 messages +``` + +### `--detail summary` (default for > 7 days) +``` +December 21, 2025 + 240 conversations, 6,858 messages + Morning: 28 convs - Repository studies + Afternoon: 119 convs - CDR automation + Evening: 93 convs - Documentation + Main project: CDR documentation (186 convs) +``` + +### `--detail normal` (current default for most commands) +``` +[Current journal/list output with conversation previews] +``` + +### `--detail verbose` (opt-in) +``` +[Current output + branch details + more message context] +``` + +### `--detail full` (opt-in, like current `show`) +``` +[Everything: all messages, all tool calls, all metadata] +``` + +--- + +## Smart Warnings + +**When output would be large:** +``` +WARNING: Requesting 30 days of data would generate ~250,000 characters + +Suggestions: + 1. Use --summary for high-level overview + 2. Use --page 1 to see first 7 days + 3. Use --detail minimal for compact output + 4. Export to file: --output month.md + +Continue anyway? (y/N) +``` + +**When export would be huge:** +``` +WARNING: Exporting 1,000 conversations would create ~15 MB file + +Suggestions: + 1. Use --summary-only to export just metadata + 2. Use --no-branches to exclude branch details + 3. Split into smaller date ranges + 4. Use --max-conversations to limit size + +Export anyway? (y/N) +``` + +--- + +## The Value + +**Unlocks New Use Cases:** +- Monthly reviews (currently impossible) +- Quarterly analysis (currently impossible) +- Year-end summaries (currently impossible) +- Long-term trend tracking (currently impossible) + +**Better User Experience:** +- No more truncated output +- No more frozen terminals +- No more "I asked for too much" regret +- Clear feedback and guidance + +**Smarter Tool:** +- Adapts detail level to request size +- Warns before creating problems +- Suggests better approaches +- Always gives useful output + +--- + +## Example Usage (Dream State) + +```bash +# Smart defaults +cycodj journal --last-days 30 # Auto-uses --summary +cycodj journal --date 2025-12-21 # Auto-uses --normal + +# Explicit control +cycodj journal --year 2025 --detail minimal +cycodj stats --month 12 --summary +cycodj export -o year.md --year 2025 --summary-only + +# Pagination for very large ranges +cycodj list --last 5000 --page 1 --page-size 100 + +# Preview before generating +cycodj export -o huge.md --year 2025 --estimate-size +# Output: "Would generate ~50 MB file, ~10,000 conversations" + +# Streaming for real-time feedback +cycodj journal --last-days 90 --stream +# Shows days as they're processed, can Ctrl+C when you have enough +``` + +--- + +## Why This Matters + +**Current limitations make the tool unusable for:** +- Long-term analysis +- Big picture views +- Trend tracking over time +- Annual reviews +- Portfolio documentation + +**The tool is GREAT for daily/weekly use but breaks at monthly/yearly scale.** + +With better large output handling: +- Tool works at ALL time scales +- Can zoom in (day) AND zoom out (year) +- Always provides useful output +- Guides users to right approach + +**Make it scale.** 📈 diff --git a/todo/cycodj-project-clustering.md b/todo/cycodj-project-clustering.md new file mode 100644 index 00000000..44aa72a1 --- /dev/null +++ b/todo/cycodj-project-clustering.md @@ -0,0 +1,426 @@ +# TODO: cycodj - Symblob Views (Multiple Organizational Dimensions) + +## The Pain 😫 + +**Current State:** +cycodj organizes conversations by TIME (journal view): +- Chronological order +- Grouped by day/time period +- Shows branch relationships +- Linear narrative + +**But reality is multi-dimensional!** + +When I look at a day's work, I have multiple valid questions: +- **Time:** What did I do in morning vs. afternoon? (have this!) +- **Topic:** What projects was I working on? (don't have this!) +- **Task:** What got accomplished vs. explored? (don't have this!) +- **Technology:** What languages/tools did I use? (don't have this!) +- **Goal:** Was I building features, fixing bugs, or learning? (don't have this!) + +**The Core Problem:** +cycodj currently has ONE view (time-based journal). +But the same data can be organized multiple ways - **like symblobs!** + +**From genesis/philosophy/meta-insights/symblob-trees-explained.md:** +> "Knowledge exists at multiple compression layers... Same reality, different representations" + +Conversations are a JOURNAL (time-based reality). +But they can also be CHAPTERS (topic-based reality). +Or TASKS (outcome-based reality). +Or TECHNOLOGIES (stack-based reality). + +**Same data, different symblob dimensions!** + +**Real-world frustration:** +Today analyzing Saturday (Dec 21) with 240 conversations: +- Had to manually figure out: "Most are CDR project, some are repo setup, a few are documentation" +- Took me 20 minutes to understand the breakdown +- **But I could have organized by TOPIC, or TASK, or TECH - all would be valid!** + +**Example:** Saturday's 240 conversations could be viewed as: + +**Time View (current - have this!):** +- Morning: 28 conversations +- Afternoon: 119 conversations +- Evening: 93 conversations + +**Topic View (want this!):** +- CDR Documentation: 186 conversations +- Repository Management: 3 conversations +- Tool Discussion: 51 conversations + +**Task View (want this!):** +- Execution (automated tasks): 180 conversations +- Exploration (design/planning): 40 conversations +- Maintenance (git/setup): 20 conversations + +**Technology View (want this!):** +- C# code analysis: 150 conversations +- Markdown documentation: 60 conversations +- Git/repo operations: 20 conversations +- Meta/planning: 10 conversations + +**All valid! All useful! But only TIME view exists currently.** + +--- + +## The Cure 💊 + +**What I Want:** +Multiple organizational views of the same conversation data - like symblob compression/decompression! + +### View 1: Topics (Project clustering) +```bash +cycodj view --by-topic --date 2025-12-21 +# or shorter: +cycodj topics --date 2025-12-21 +``` + +**Output:** +``` +## Topic View: December 21, 2025 + +### CDR Documentation (186 conversations, 77.5%) +Pattern: "Read attempt1/STATUS.md and do the next step" +Time: 09:45 - 16:53 +Status: Completed ✅ +Key files: cdr/STATUS.md, cdr/final/*.md + +Conversations: + 09:45 - Started Phase 1 + 10:01 - Phase 2 + 10:06 - Phase 3 + ... [183 more] + 16:53 - Final phase completed + +### Repository Management (3 conversations, 1.2%) +Pattern: "Make a new worktree", "Change git remote" +Time: 21:05 - 21:10 +Status: Completed ✅ + +### Tool Discussion (51 conversations, 21.3%) +Pattern: "How should cycodj work?" +Time: 21:09 - 21:15 +Status: Discussion + +Total: 240 conversations across 3 topics +``` + +### View 2: Tasks (Outcome-based) + +### View 2: Tasks (Outcome-based) +```bash +cycodj view --by-task --date 2025-12-21 +# or: +cycodj tasks --date 2025-12-21 +``` + +**Output:** +``` +## Task View: December 21, 2025 + +### Execution (180 conversations, 75%) +Automated/scripted work following defined process +Examples: + - Reading STATUS.md and executing next step + - Following templates + - Repetitive operations + +### Exploration (40 conversations, 16.7%) +Discovery, design, planning work +Examples: + - "How should this work?" + - Design discussions + - Architecture decisions + +### Maintenance (20 conversations, 8.3%) +Infrastructure, setup, housekeeping +Examples: + - Git operations + - Repository setup + - Tool configuration + +Total: 240 conversations +Productivity: 75% execution shows automation working! +``` + +### View 3: Technology (Stack-based) +```bash +cycodj view --by-tech --date 2025-12-21 +# or: +cycodj tech --date 2025-12-21 +``` + +**Output:** +``` +## Technology View: December 21, 2025 + +### C# (.cs files, compilation, testing) - 150 conversations +### Markdown (.md files, documentation) - 60 conversations +### Git (repos, branches, commits) - 20 conversations +### Meta (planning, discussion) - 10 conversations + +Total: 240 conversations +Focus: 62.5% coding, 25% documentation, 12.5% infrastructure +``` + +### View 4: Goals (Intent-based) +```bash +cycodj view --by-goal --date 2025-12-21 +``` + +**Output:** +``` +## Goal View: December 21, 2025 + +### Feature Development (180 conversations, 75%) +Building new capabilities + +### Bug Fixes (30 conversations, 12.5%) +Fixing issues + +### Learning/Research (20 conversations, 8.3%) +Understanding existing code or new concepts + +### Refactoring (10 conversations, 4.2%) +Improving existing code + +Total: 240 conversations +Balance: 75% building, 12.5% fixing, 12.5% improving/learning +``` + +**Why This Helps:** +- **Multiple perspectives:** Same data, different insights +- **Choose your view:** Pick the organization that answers your question +- **Pattern detection:** Tool finds themes automatically +- **Symblob compression:** Each view is a valid compression of reality +- **Shareable:** Export any view as summary report + +--- + +## User Stories + +### Story 1: Daily Standup (Topic View) +**As a user,** I want to quickly summarize what projects I worked on yesterday +**So that** I can report in standup without spending 10 minutes figuring it out +**Currently:** Read through all conversations manually, try to remember +**Desired:** Run `cycodj topics --yesterday` and get instant project breakdown + +### Story 2: Weekly Review (Multiple Views) +**As a user,** I want to see what projects consumed my time AND how much was execution vs. exploration +**So that** I can identify where I'm spending effort and how balanced my work is +**Currently:** No way to group multi-day work by any dimension +**Desired:** Run `cycodj topics --last-days 7` then `cycodj tasks --last-days 7` to see different perspectives + +### Story 3: Project Tracking (Topic View Over Time) +**As a user,** I want to track progress on a specific project over time +**So that** I can see if it's moving forward or stalled +**Currently:** Have to manually grep for project name across days +**Desired:** Tool shows "cycodgr: 15 convs Mon, 8 convs Tue, 2 convs Wed" trend + +### Story 4: Work Balance Audit (Goal/Task Views) +**As a user,** I want to know if I'm spending too much time on one type of work +**So that** I can rebalance between building, fixing, and learning +**Currently:** No visibility into intent/goal distribution +**Desired:** See "80% building features, 15% bug fixes, 5% learning" and realize I need more learning time + +### Story 5: Technology Focus (Tech View) +**As a user,** I want to see what languages/tools dominated my week +**So that** I can report on technical work or identify skill gaps +**Currently:** No way to filter/group by technology used +**Desired:** "Worked with C# (60%), TypeScript (30%), Markdown (10%)" view + +### Story 6: Cross-Dimensional Analysis +**As a user,** I want to combine views +**So that** I can ask complex questions like "What percentage of C# work was bug fixes vs. features?" +**Currently:** Impossible - only have time view +**Desired:** Filter topics by tech, or tech by goal, etc. + +--- + +## How Should Clustering Work? + +### Approach 1: Pattern Matching (Simple) +Detect common patterns: +- Same repeated user message → Same project +- Same file patterns (cdr/*, todo/*, etc.) → Same project +- Same tools used → Likely related +- Branching from same root → Same project + +### Approach 2: Semantic Grouping (Better) +Use conversation titles: +- "CDR Project" → CDR project +- "Book Summary" → Book automation +- "cycodgr" → Tool development + +### Approach 3: AI Clustering (Best) +Use `--instructions` to let AI cluster: +```bash +cycodj topics --date 2025-12-21 \ + --instructions "Group these conversations by project/theme" +``` + +--- + +## Success Criteria + +**This is solved when:** + +1. ✅ Can run `cycodj view --by-` for multiple organizational dimensions +2. ✅ Minimum dimensions supported: `--by-topic`, `--by-task`, `--by-tech`, `--by-goal` +3. ✅ Each view shows: grouping, counts, percentages, time ranges +4. ✅ Can see most active group at a glance in any view +5. ✅ Works across date ranges (daily, weekly, monthly) +6. ✅ Can export any view to markdown +7. ✅ Views work with existing filter options (--date, --from/--to, etc.) + +**Bonus points if:** +- Can combine views: `cycodj view --by-topic --filter-tech "C#"` (C# topics) +- Shows cross-dimensional insights: "cycodgr project: 60% C#, 40% docs" +- Detects when projects start/end automatically +- Shows project status (active, completed, stalled) +- Can define custom dimensions via --instructions +- Warns about unbalanced distributions: "90% bug fixes, only 10% features!" +- Can filter other commands by dimension: `cycodj list --topic cycodgr` +- Supports "chapterization" within single conversations (topics within one chat) + +--- + +## The Value + +**Time Saved:** +- Current: 15-20 minutes to manually analyze daily work +- Future: 10 seconds to see automatic breakdown +- **100x faster** + +**Insights Gained:** +- See patterns: "I spend 70% of time debugging, 30% building" +- Spot problems: "This project has been stalled for a week" +- Track progress: "We moved from 5 convs/day to 50 convs/day" (automation working!) + +**Communication:** +- Quick summaries for standups +- Project status reports +- Time tracking for billing/reporting + +--- + +## Example Usage (Dream State) + +```bash +# What am I working on? +cycodj topics --yesterday + +# Show weekly project distribution +cycodj topics --last-days 7 + +# Focus on specific project +cycodj topics --date 2025-12-21 --project "CDR" + +# Export project summary +cycodj topics --month --format markdown > december-projects.md + +# Find stalled projects +cycodj topics --last-days 30 --stalled + +# Compare time distribution this week vs last +cycodj topics --this-week +cycodj topics --last-week + +# Use AI for smart grouping +cycodj topics --last-days 7 \ + --instructions "Group by strategic goal, not just project name" +``` + +--- + +## Real-World Example + +**What I did manually today:** + +Looking at Saturday Dec 21 (240 conversations): + +1. Read through journal output (5 minutes) +2. Noticed pattern: Lots of "Read STATUS..." (2 minutes) +3. Searched for specific terms to confirm (3 minutes) +4. Counted conversations manually (5 minutes) +5. Figured out: CDR project dominated the day (5 minutes) + +**Total: 20 minutes of manual analysis** + +**What I wanted:** + +```bash +cycodj topics --date 2025-12-21 +``` + +Output in 3 seconds: +- CDR Documentation: 186 convs (77.5%) +- Repo Management: 3 convs (1.2%) +- Other: 51 convs (21.3%) + +**The tool should work for me, not make me work for it.** + +--- + +## Why This Matters + +**The Symblob Principle:** +Same conversation data, multiple valid representations - like how a book exists as: +- Title (ultra-compressed) +- Table of contents (structural) +- Chapter summaries (compressed) +- Full text (decompressed) + +Conversations exist as: +- **Time view:** When did work happen? (journal - have this!) +- **Topic view:** What projects were involved? (chapters - need this!) +- **Task view:** What got accomplished? (outcomes - need this!) +- **Tech view:** What technologies were used? (stack - need this!) +- **Goal view:** What was the intent? (purpose - need this!) + +**Questions each view answers:** + +**Time View:** +- When was I most productive? +- What did I do in the afternoon? +- How did my day flow? + +**Topic View:** +- What projects did I work on? +- How much time per project? +- Which projects are active vs. stalled? + +**Task View:** +- How much was execution vs. exploration? +- Am I in "doing" mode or "thinking" mode? +- Is automation working? (high % execution = yes!) + +**Tech View:** +- What languages/tools dominated? +- Am I balanced across stack? +- Where are my skill gaps? + +**Goal View:** +- Am I building or fixing? +- Too much bug fixing, not enough features? +- Enough time for learning/research? + +**Without multiple views:** +- I have raw data but limited insights +- I'm the data analyst, not the user +- The tool shows me everything but tells me little + +**With symblob views:** +- Tool provides multiple perspectives automatically +- I choose the view that answers MY question +- Same data, infinite insights + +**Chapterization:** +Not just time-based narrative, but meaning-based organization. +Like a meeting transcript becomes meeting notes organized by topic, not by chronology. + +**Data → Information → Insight → Wisdom** 📊 + +This is the difference between a journal and an understanding. diff --git a/todo/cycodj-trajectory-format.md b/todo/cycodj-trajectory-format.md new file mode 100644 index 00000000..db586037 --- /dev/null +++ b/todo/cycodj-trajectory-format.md @@ -0,0 +1,34 @@ +# Investigate Trajectory Format Support for cycodj export + +## Context +User noticed that `cycodj export` creates a different format than "trajectory format" and questioned why we did that. + +## Questions to Investigate +1. What is "trajectory format"? + - Is this a format used by cycod already? + - Is it a format used for AI training/fine-tuning? + - Where is it documented/implemented? + +2. Should `cycodj export` support trajectory format? + - As default format? + - As an option like `--format trajectory` or `--format markdown`? + +3. What are the differences between the current export format and trajectory format? + - Current export: Markdown with emojis, blockquotes, table of contents + - Trajectory format: ??? + +## Related Code +- `src/cycodj/CommandLineCommands/ExportCommand.cs` + +## Next Steps +1. Find/document trajectory format specification +2. Compare with current export format +3. Decide if we should: + - Replace current format + - Add as an option + - Keep both +4. Implement if needed + +## Notes +- Current export format is very readable markdown with nice formatting +- If trajectory format is for machine consumption (training data), we might want both