From 5756322f5d17669a5c84c25f0b3a233a086170f5 Mon Sep 17 00:00:00 2001 From: telli Date: Wed, 6 May 2026 13:24:33 -0700 Subject: [PATCH] Control-plane parity plus enhancements --- .../Internal/ProviderBackedAgentKernel.cs | 22 +- .../Models/AgentRunContext.cs | 4 +- .../Services/AgentFrameworkBridge.cs | 44 +- .../CliServiceCollectionExtensions.cs | 22 + .../Terminal/SpectreReplTerminal.cs | 4 + .../Abstractions/IReplTerminal.cs | 5 + .../Handlers/ApprovalsSlashCommandHandler.cs | 66 +-- .../Handlers/AuthCommandHandler.cs | 241 ++++++++++ .../Handlers/ClearSlashCommandHandler.cs | 31 ++ .../Handlers/EvolutionCommandHandler.cs | 166 +++++++ .../Handlers/InitCommandHandler.cs | 75 ++++ .../Handlers/ModeSlashCommandHandler.cs | 5 +- .../Handlers/ModelSlashCommandHandler.cs | 25 ++ .../Handlers/ModelsCommandHandler.cs | 115 ++++- .../Handlers/NewSessionSlashCommandHandler.cs | 49 +++ .../Handlers/PermissionsCommandHandler.cs | 315 +++++++++++++ .../Handlers/ResearchCommandHandler.cs | 107 +++++ .../Handlers/ResumeSlashCommandHandler.cs | 40 ++ .../Handlers/ScheduleCommandHandler.cs | 319 ++++++++++++++ .../Models/CommandExecutionContext.cs | 4 +- .../Options/GlobalCliOptions.cs | 3 +- src/SharpClaw.Code.Commands/Repl/ReplHost.cs | 2 + .../Repl/ReplInteractionState.cs | 16 + .../IRuntimeStoragePathResolver.cs | 12 + .../Abstractions/ISecretProtector.cs | 22 + ...frastructureServiceCollectionExtensions.cs | 1 + .../Services/PlatformSecretProtector.cs | 38 ++ .../Services/RuntimeStoragePathResolver.cs | 16 + .../Rules/PrimaryModeMutationRule.cs | 10 +- .../Commands/RunPromptRequest.cs | 4 +- .../Enums/PrimaryMode.cs | 6 + .../Models/AdaLGapModels.cs | 198 +++++++++ .../Models/AuthStatus.cs | 8 +- .../Models/ContentBlock.cs | 14 +- .../Models/OpenCodeParityModels.cs | 2 + .../Models/PromptReferences.cs | 10 +- .../Models/ProviderRequest.cs | 4 +- .../Models/SharpClawWorkflowMetadataKeys.cs | 12 + .../Serialization/ProtocolJsonContext.cs | 20 + .../Abstractions/IModelProvider.cs | 5 + .../Abstractions/IProviderCredentialStore.cs | 32 ++ .../AnthropicProvider.cs | 34 +- .../Configuration/AnthropicProviderOptions.cs | 5 + .../OpenAiCompatibleProviderOptions.cs | 5 + .../Internal/AnthropicMessageBuilder.cs | 23 + .../Internal/OpenAiMessageBuilder.cs | 6 + .../Internal/ProviderAuthStatusFactory.cs | 10 +- .../Models/ProviderCredentialModels.cs | 18 + .../OpenAiCompatibleProvider.cs | 53 ++- .../ProvidersServiceCollectionExtensions.cs | 1 + .../Services/ProviderCatalogService.cs | 4 + .../Services/ProviderCredentialStore.cs | 137 ++++++ .../Abstractions/IEvolutionProposalService.cs | 38 ++ .../Abstractions/IResearchWorkflowService.cs | 14 + .../Abstractions/IScheduledPromptService.cs | 45 ++ .../Abstractions/ISessionPreferenceService.cs | 84 ++++ .../IWorkspaceBootstrapService.cs | 26 ++ .../RuntimeServiceCollectionExtensions.cs | 12 + .../Configuration/SharpClawConfigService.cs | 12 +- .../Context/PromptContextAssembler.cs | 75 +++- .../Context/PromptExecutionContext.cs | 4 +- .../Prompts/PromptReferenceResolver.cs | 214 ++++++++- .../Turns/DefaultTurnRunner.cs | 3 +- .../Workflow/EvolutionProposalService.cs | 412 ++++++++++++++++++ .../Workflow/ResearchWorkflowService.cs | 31 ++ .../Workflow/ScheduleCronExpression.cs | 135 ++++++ .../Workflow/ScheduledPromptRunner.cs | 61 +++ .../Workflow/ScheduledPromptService.cs | 227 ++++++++++ .../Workflow/SessionPreferenceService.cs | 314 +++++++++++++ .../Workflow/WorkspaceBootstrapService.cs | 76 ++++ .../Abstractions/IEvolutionProposalStore.cs | 29 ++ .../Abstractions/IScheduledPromptStore.cs | 29 ++ .../Storage/FileEvolutionProposalStore.cs | 85 ++++ .../Storage/FileScheduledPromptStore.cs | 86 ++++ .../HostAwareEvolutionProposalStore.cs | 35 ++ .../Storage/HostAwareScheduledPromptStore.cs | 35 ++ .../Storage/SqliteEvolutionProposalStore.cs | 95 ++++ .../Storage/SqliteScheduledPromptStore.cs | 99 +++++ .../Storage/SqliteSessionStoreDatabase.cs | 17 + 79 files changed, 4547 insertions(+), 136 deletions(-) create mode 100644 src/SharpClaw.Code.Commands/Handlers/AuthCommandHandler.cs create mode 100644 src/SharpClaw.Code.Commands/Handlers/ClearSlashCommandHandler.cs create mode 100644 src/SharpClaw.Code.Commands/Handlers/EvolutionCommandHandler.cs create mode 100644 src/SharpClaw.Code.Commands/Handlers/InitCommandHandler.cs create mode 100644 src/SharpClaw.Code.Commands/Handlers/ModelSlashCommandHandler.cs create mode 100644 src/SharpClaw.Code.Commands/Handlers/NewSessionSlashCommandHandler.cs create mode 100644 src/SharpClaw.Code.Commands/Handlers/PermissionsCommandHandler.cs create mode 100644 src/SharpClaw.Code.Commands/Handlers/ResearchCommandHandler.cs create mode 100644 src/SharpClaw.Code.Commands/Handlers/ResumeSlashCommandHandler.cs create mode 100644 src/SharpClaw.Code.Commands/Handlers/ScheduleCommandHandler.cs create mode 100644 src/SharpClaw.Code.Infrastructure/Abstractions/ISecretProtector.cs create mode 100644 src/SharpClaw.Code.Infrastructure/Services/PlatformSecretProtector.cs create mode 100644 src/SharpClaw.Code.Protocol/Models/AdaLGapModels.cs create mode 100644 src/SharpClaw.Code.Providers/Abstractions/IProviderCredentialStore.cs create mode 100644 src/SharpClaw.Code.Providers/Models/ProviderCredentialModels.cs create mode 100644 src/SharpClaw.Code.Providers/Services/ProviderCredentialStore.cs create mode 100644 src/SharpClaw.Code.Runtime/Abstractions/IEvolutionProposalService.cs create mode 100644 src/SharpClaw.Code.Runtime/Abstractions/IResearchWorkflowService.cs create mode 100644 src/SharpClaw.Code.Runtime/Abstractions/IScheduledPromptService.cs create mode 100644 src/SharpClaw.Code.Runtime/Abstractions/ISessionPreferenceService.cs create mode 100644 src/SharpClaw.Code.Runtime/Abstractions/IWorkspaceBootstrapService.cs create mode 100644 src/SharpClaw.Code.Runtime/Workflow/EvolutionProposalService.cs create mode 100644 src/SharpClaw.Code.Runtime/Workflow/ResearchWorkflowService.cs create mode 100644 src/SharpClaw.Code.Runtime/Workflow/ScheduleCronExpression.cs create mode 100644 src/SharpClaw.Code.Runtime/Workflow/ScheduledPromptRunner.cs create mode 100644 src/SharpClaw.Code.Runtime/Workflow/ScheduledPromptService.cs create mode 100644 src/SharpClaw.Code.Runtime/Workflow/SessionPreferenceService.cs create mode 100644 src/SharpClaw.Code.Runtime/Workflow/WorkspaceBootstrapService.cs create mode 100644 src/SharpClaw.Code.Sessions/Abstractions/IEvolutionProposalStore.cs create mode 100644 src/SharpClaw.Code.Sessions/Abstractions/IScheduledPromptStore.cs create mode 100644 src/SharpClaw.Code.Sessions/Storage/FileEvolutionProposalStore.cs create mode 100644 src/SharpClaw.Code.Sessions/Storage/FileScheduledPromptStore.cs create mode 100644 src/SharpClaw.Code.Sessions/Storage/HostAwareEvolutionProposalStore.cs create mode 100644 src/SharpClaw.Code.Sessions/Storage/HostAwareScheduledPromptStore.cs create mode 100644 src/SharpClaw.Code.Sessions/Storage/SqliteEvolutionProposalStore.cs create mode 100644 src/SharpClaw.Code.Sessions/Storage/SqliteScheduledPromptStore.cs diff --git a/src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs b/src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs index 49c9439..044f10a 100644 --- a/src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs +++ b/src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs @@ -52,7 +52,8 @@ internal async Task ExecuteAsync( SystemPrompt: request.Instructions, OutputFormat: request.Context.OutputFormat, Temperature: 0.1m, - Metadata: baseMetadata)); + Metadata: baseMetadata, + ContainsImageInput: request.Context.UserContent?.Any(static block => block.Kind == ContentBlockKind.Image) == true)); var resolvedProviderName = resolvedRequest.ProviderName; @@ -102,6 +103,16 @@ internal async Task ExecuteAsync( throw CreateMissingProviderException(resolvedProviderName, requestedModel, "provider resolution"); } + if (request.Context.UserContent?.Any(static block => block.Kind == ContentBlockKind.Image) == true + && !provider.SupportsImageInput) + { + throw new ProviderExecutionException( + resolvedProviderName, + requestedModel, + ProviderFailureKind.StreamFailed, + $"Provider '{resolvedProviderName}' does not support structured image input."); + } + // --- Build initial conversation messages --- // Do not add request.Instructions as a shared "system" chat message here. // Provider adapters apply system instructions via ProviderRequest.SystemPrompt @@ -115,7 +126,11 @@ internal async Task ExecuteAsync( messages.AddRange(history); } - messages.Add(new ChatMessage("user", [new ContentBlock(ContentBlockKind.Text, request.Context.Prompt, null, null, null, null)])); + messages.Add(new ChatMessage( + "user", + request.Context.UserContent?.Count > 0 + ? request.Context.UserContent + : [new ContentBlock(ContentBlockKind.Text, request.Context.Prompt, null, null, null, null)])); // --- Tool-calling loop --- var allProviderEvents = new List(); @@ -143,7 +158,8 @@ internal async Task ExecuteAsync( Metadata: baseMetadata, Messages: messages, Tools: availableTools, - MaxTokens: options.MaxTokensPerRequest)); + MaxTokens: options.MaxTokensPerRequest, + ContainsImageInput: messages.Any(static message => message.Content.Any(static block => block.Kind == ContentBlockKind.Image)))); lastProviderRequest = providerRequest; diff --git a/src/SharpClaw.Code.Agents/Models/AgentRunContext.cs b/src/SharpClaw.Code.Agents/Models/AgentRunContext.cs index b5e36d4..d9a6541 100644 --- a/src/SharpClaw.Code.Agents/Models/AgentRunContext.cs +++ b/src/SharpClaw.Code.Agents/Models/AgentRunContext.cs @@ -26,6 +26,7 @@ namespace SharpClaw.Code.Agents.Models; /// /// Whether tool approvals can interact with the caller. /// Optional bounded auto-approval settings forwarded to tool execution. +/// Optional structured user content blocks for the current turn. public sealed record AgentRunContext( string SessionId, string TurnId, @@ -42,4 +43,5 @@ public sealed record AgentRunContext( IToolMutationRecorder? ToolMutationRecorder = null, IReadOnlyList? ConversationHistory = null, bool IsInteractive = true, - ApprovalSettings? ApprovalSettings = null); + ApprovalSettings? ApprovalSettings = null, + IReadOnlyList? UserContent = null); diff --git a/src/SharpClaw.Code.Agents/Services/AgentFrameworkBridge.cs b/src/SharpClaw.Code.Agents/Services/AgentFrameworkBridge.cs index 2cd6787..433643d 100644 --- a/src/SharpClaw.Code.Agents/Services/AgentFrameworkBridge.cs +++ b/src/SharpClaw.Code.Agents/Services/AgentFrameworkBridge.cs @@ -32,6 +32,9 @@ public async Task RunAsync(AgentFrameworkRequest request, Cancel { ArgumentNullException.ThrowIfNull(request); var allowedTools = ResolveAllowedTools(request.Context.Metadata); + var trustedPluginNames = ResolveTrustedNames(request.Context.Metadata, SharpClawWorkflowMetadataKeys.TrustedPluginNamesJson); + var trustedMcpServerNames = ResolveTrustedNames(request.Context.Metadata, SharpClawWorkflowMetadataKeys.TrustedMcpServerNamesJson); + var effectivePermissionMode = ResolvePermissionMode(request.Context.Metadata, request.Context.PermissionMode); // Build tool execution context from agent run context var toolExecutionContext = new ToolExecutionContext( @@ -39,7 +42,7 @@ public async Task RunAsync(AgentFrameworkRequest request, Cancel TurnId: request.Context.TurnId, WorkspaceRoot: request.Context.WorkingDirectory, WorkingDirectory: request.Context.WorkingDirectory, - PermissionMode: request.Context.PermissionMode, + PermissionMode: effectivePermissionMode, OutputFormat: request.Context.OutputFormat, EnvironmentVariables: null, Model: request.Context.Model, @@ -54,8 +57,8 @@ public async Task RunAsync(AgentFrameworkRequest request, Cancel && string.Equals(acp, "true", StringComparison.OrdinalIgnoreCase) ? "acp" : null, - TrustedPluginNames: null, - TrustedMcpServerNames: null, + TrustedPluginNames: trustedPluginNames, + TrustedMcpServerNames: trustedMcpServerNames, PrimaryMode: request.Context.PrimaryMode, MutationRecorder: request.Context.ToolMutationRecorder, ApprovalSettings: request.Context.ApprovalSettings); @@ -168,6 +171,41 @@ public async Task RunAsync(AgentFrameworkRequest request, Cancel } } + private static IReadOnlyCollection? ResolveTrustedNames( + IReadOnlyDictionary? metadata, + string metadataKey) + { + if (metadata is null + || !metadata.TryGetValue(metadataKey, out var payload) + || string.IsNullOrWhiteSpace(payload)) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(payload, ProtocolJsonContext.Default.StringArray); + } + catch (JsonException) + { + return null; + } + } + + private static PermissionMode ResolvePermissionMode( + IReadOnlyDictionary? metadata, + PermissionMode fallback) + { + if (metadata is not null + && metadata.TryGetValue(SharpClawWorkflowMetadataKeys.PreferredPermissionMode, out var payload) + && Enum.TryParse(payload, ignoreCase: true, out var parsed)) + { + return parsed; + } + + return fallback; + } + private static IEnumerable FilterAdvertisedTools( IReadOnlyList registryTools, IReadOnlyCollection? allowedTools) diff --git a/src/SharpClaw.Code.Cli/Composition/CliServiceCollectionExtensions.cs b/src/SharpClaw.Code.Cli/Composition/CliServiceCollectionExtensions.cs index ce46f48..5a73f30 100644 --- a/src/SharpClaw.Code.Cli/Composition/CliServiceCollectionExtensions.cs +++ b/src/SharpClaw.Code.Cli/Composition/CliServiceCollectionExtensions.cs @@ -30,11 +30,23 @@ public static IServiceCollection AddSharpClawCli(this IServiceCollection service services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); + services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); services.AddSingleton(); + services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); + services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); + services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); + services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); + services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -62,7 +74,14 @@ public static IServiceCollection AddSharpClawCli(this IServiceCollection service services.AddSingleton(); services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); services.AddSingleton(); + services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); + services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); + services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); + services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); + services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -86,6 +105,9 @@ public static IServiceCollection AddSharpClawCli(this IServiceCollection service services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/SharpClaw.Code.Cli/Terminal/SpectreReplTerminal.cs b/src/SharpClaw.Code.Cli/Terminal/SpectreReplTerminal.cs index 370a8d4..7007b87 100644 --- a/src/SharpClaw.Code.Cli/Terminal/SpectreReplTerminal.cs +++ b/src/SharpClaw.Code.Cli/Terminal/SpectreReplTerminal.cs @@ -33,4 +33,8 @@ public void WriteInfo(string message) /// public void WriteError(string message) => AnsiConsole.MarkupLine($"[red]{Markup.Escape(message)}[/]"); + + /// + public void ClearScreen() + => AnsiConsole.Clear(); } diff --git a/src/SharpClaw.Code.Commands/Abstractions/IReplTerminal.cs b/src/SharpClaw.Code.Commands/Abstractions/IReplTerminal.cs index b1f52ae..184954a 100644 --- a/src/SharpClaw.Code.Commands/Abstractions/IReplTerminal.cs +++ b/src/SharpClaw.Code.Commands/Abstractions/IReplTerminal.cs @@ -29,4 +29,9 @@ public interface IReplTerminal /// /// The message to write. void WriteError(string message); + + /// + /// Clears the visible terminal screen. + /// + void ClearScreen(); } diff --git a/src/SharpClaw.Code.Commands/Handlers/ApprovalsSlashCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/ApprovalsSlashCommandHandler.cs index ce269de..1b7009c 100644 --- a/src/SharpClaw.Code.Commands/Handlers/ApprovalsSlashCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/ApprovalsSlashCommandHandler.cs @@ -1,73 +1,27 @@ using SharpClaw.Code.Commands.Models; -using SharpClaw.Code.Commands.Options; -using SharpClaw.Code.Protocol.Commands; -using SharpClaw.Code.Protocol.Models; namespace SharpClaw.Code.Commands; /// -/// Shows or adjusts bounded auto-approval settings for REPL-driven prompts. +/// Provides a durable alias over the permissions auto-approval subcommands. /// -public sealed class ApprovalsSlashCommandHandler( - ReplInteractionState replState, - OutputRendererDispatcher outputRendererDispatcher) : ISlashCommandHandler +public sealed class ApprovalsSlashCommandHandler(PermissionsCommandHandler permissionsCommandHandler) : ISlashCommandHandler { /// public string CommandName => "approvals"; /// - public string Description => "Shows or sets auto-approval scopes and budget for REPL prompts."; + public string Description => "Alias for /permissions approvals show|set|clear."; /// public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) - { - if (command.Arguments.Length == 0) - { - var effective = replState.ApprovalSettingsOverride ?? context.ApprovalSettings; - return RenderAsync( - $"Auto-approvals: {ApprovalSettingsText.RenderSummary(effective)} (override: {(replState.ApprovalSettingsOverride is null ? "none" : ApprovalSettingsText.RenderSummary(replState.ApprovalSettingsOverride))}).", - context, - cancellationToken); - } - - if (string.Equals(command.Arguments[0], "reset", StringComparison.OrdinalIgnoreCase) - || string.Equals(command.Arguments[0], "clear", StringComparison.OrdinalIgnoreCase)) - { - replState.ApprovalSettingsOverride = null; - return RenderAsync("Auto-approval reset for the next prompt.", context, cancellationToken); - } - - if (!string.Equals(command.Arguments[0], "set", StringComparison.OrdinalIgnoreCase) || command.Arguments.Length < 2) - { - return RenderAsync("Usage: /approvals [set [budget]|reset]", context, cancellationToken, success: false); - } - - var budget = command.Arguments.Length >= 3 - ? ParseBudget(command.Arguments[2]) - : null; - var settings = ApprovalSettingsText.Parse(command.Arguments[1], budget) ?? ApprovalSettings.Empty; - replState.ApprovalSettingsOverride = settings; - return RenderAsync( - $"Auto-approval override set to {ApprovalSettingsText.RenderSummary(settings)}.", + => permissionsCommandHandler.ExecuteAsync( + new SlashCommandParseResult( + true, + "permissions", + command.Arguments.Length == 0 + ? ["approvals", "show"] + : ["approvals", .. command.Arguments]), context, cancellationToken); - } - - private async Task RenderAsync( - string message, - CommandExecutionContext context, - CancellationToken cancellationToken, - bool success = true) - { - await outputRendererDispatcher.RenderCommandResultAsync( - new CommandResult(success, success ? 0 : 1, context.OutputFormat, message, null), - context.OutputFormat, - cancellationToken).ConfigureAwait(false); - return success ? 0 : 1; - } - - private static int? ParseBudget(string value) - => int.TryParse(value, out var parsed) && parsed > 0 - ? parsed - : throw new InvalidOperationException($"Invalid auto-approve budget '{value}'."); } diff --git a/src/SharpClaw.Code.Commands/Handlers/AuthCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/AuthCommandHandler.cs new file mode 100644 index 0000000..503adf0 --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/AuthCommandHandler.cs @@ -0,0 +1,241 @@ +using System.CommandLine; +using System.Text; +using System.Text.Json; +using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Commands.Options; +using SharpClaw.Code.Providers.Abstractions; +using SharpClaw.Code.Protocol.Commands; + +namespace SharpClaw.Code.Commands; + +/// +/// Shows and updates user-scoped provider credential references. +/// +public sealed class AuthCommandHandler( + IProviderCredentialStore providerCredentialStore, + IProviderCatalogService providerCatalogService, + ICliInvocationEnvironment cliInvocationEnvironment, + OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler, ISlashCommandHandler +{ + /// + public string Name => "auth"; + + /// + public string Description => "Shows provider auth status and manages user-scoped BYOAK credentials."; + + /// + public string CommandName => Name; + + /// + public Command BuildCommand(GlobalCliOptions globalOptions) + { + var command = new Command(Name, Description); + + var status = new Command("status", "Shows provider authentication status."); + var statusProvider = new Option("--provider") { Description = "Optional provider name to inspect." }; + status.Options.Add(statusProvider); + status.SetAction((parseResult, cancellationToken) => ExecuteStatusAsync( + globalOptions.Resolve(parseResult), + parseResult.GetValue(statusProvider), + cancellationToken)); + command.Subcommands.Add(status); + + var list = new Command("list", "Lists stored credential descriptors without revealing secret material."); + list.SetAction((parseResult, cancellationToken) => ExecuteListAsync(globalOptions.Resolve(parseResult), cancellationToken)); + command.Subcommands.Add(list); + + var setKey = new Command("set-key", "Stores a credential reference for one provider."); + var providerOption = new Option("--provider") { Required = true, Description = "Provider name." }; + var envVarOption = new Option("--env-var") { Description = "Environment variable name to read the API key from." }; + var stdinOption = new Option("--stdin") { Description = "Read the API key from standard input." }; + setKey.Options.Add(providerOption); + setKey.Options.Add(envVarOption); + setKey.Options.Add(stdinOption); + setKey.SetAction((parseResult, cancellationToken) => ExecuteSetKeyAsync( + parseResult.GetValue(providerOption) ?? throw new InvalidOperationException("--provider is required."), + parseResult.GetValue(envVarOption), + parseResult.GetValue(stdinOption), + globalOptions.Resolve(parseResult), + cancellationToken)); + command.Subcommands.Add(setKey); + + var clearKey = new Command("clear-key", "Clears the stored credential reference for one provider."); + var clearProviderOption = new Option("--provider") { Required = true, Description = "Provider name." }; + clearKey.Options.Add(clearProviderOption); + clearKey.SetAction((parseResult, cancellationToken) => ExecuteClearKeyAsync( + parseResult.GetValue(clearProviderOption) ?? throw new InvalidOperationException("--provider is required."), + globalOptions.Resolve(parseResult), + cancellationToken)); + command.Subcommands.Add(clearKey); + + command.SetAction((parseResult, cancellationToken) => ExecuteStatusAsync(globalOptions.Resolve(parseResult), null, cancellationToken)); + return command; + } + + /// + public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) + { + if (command.Arguments.Length == 0 || string.Equals(command.Arguments[0], "status", StringComparison.OrdinalIgnoreCase)) + { + var provider = command.Arguments.Length >= 2 ? command.Arguments[1] : null; + return ExecuteStatusAsync(context, provider, cancellationToken); + } + + if (string.Equals(command.Arguments[0], "list", StringComparison.OrdinalIgnoreCase)) + { + return ExecuteListAsync(context, cancellationToken); + } + + if (string.Equals(command.Arguments[0], "clear-key", StringComparison.OrdinalIgnoreCase) && command.Arguments.Length >= 2) + { + return ExecuteClearKeyAsync(command.Arguments[1], context, cancellationToken); + } + + if (string.Equals(command.Arguments[0], "set-key", StringComparison.OrdinalIgnoreCase) && command.Arguments.Length >= 2) + { + if (command.Arguments.Length >= 4 && string.Equals(command.Arguments[2], "--env-var", StringComparison.OrdinalIgnoreCase)) + { + return ExecuteSetKeyAsync(command.Arguments[1], command.Arguments[3], false, context, cancellationToken); + } + + if (command.Arguments.Length >= 3 && string.Equals(command.Arguments[2], "--stdin", StringComparison.OrdinalIgnoreCase)) + { + return ExecuteSetKeyAsync(command.Arguments[1], null, true, context, cancellationToken); + } + + return ExecuteSetKeyAsync(command.Arguments[1], null, false, context, cancellationToken); + } + + return RenderAsync("Usage: /auth [status [provider]|list|set-key [--env-var NAME|--stdin]|clear-key ]", context, false, cancellationToken); + } + + private async Task ExecuteStatusAsync(CommandExecutionContext context, string? providerName, CancellationToken cancellationToken) + { + var entries = await providerCatalogService.ListAsync(cancellationToken).ConfigureAwait(false); + var filtered = string.IsNullOrWhiteSpace(providerName) + ? entries + : entries.Where(entry => string.Equals(entry.ProviderName, providerName, StringComparison.OrdinalIgnoreCase)).ToArray(); + if (filtered.Count == 0) + { + return await RenderAsync($"No provider '{providerName}' was found.", context, false, cancellationToken).ConfigureAwait(false); + } + + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(true, 0, context.OutputFormat, $"{filtered.Count} provider auth status entr{(filtered.Count == 1 ? "y" : "ies")}.", JsonSerializer.Serialize(filtered)), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return 0; + } + + private async Task ExecuteListAsync(CommandExecutionContext context, CancellationToken cancellationToken) + { + var entries = await providerCredentialStore.ListAsync(cancellationToken).ConfigureAwait(false); + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(true, 0, context.OutputFormat, $"{entries.Count} stored credential descriptor(s).", JsonSerializer.Serialize(entries)), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return 0; + } + + private async Task ExecuteSetKeyAsync( + string providerName, + string? environmentVariableName, + bool useStdin, + CommandExecutionContext context, + CancellationToken cancellationToken) + { + await EnsureProviderExistsAsync(providerName, cancellationToken).ConfigureAwait(false); + + if (!string.IsNullOrWhiteSpace(environmentVariableName)) + { + await providerCredentialStore.SetEnvironmentVariableAsync(providerName, environmentVariableName.Trim(), cancellationToken).ConfigureAwait(false); + return await RenderAsync( + $"Stored credential reference for provider '{providerName}' via environment variable {environmentVariableName.Trim()}.", + context, + cancellationToken).ConfigureAwait(false); + } + + string secret; + if (useStdin) + { + secret = (await cliInvocationEnvironment.ReadStandardInputToEndAsync(cancellationToken).ConfigureAwait(false)).Trim(); + } + else if (!cliInvocationEnvironment.IsInputRedirected) + { + secret = ReadSecretFromConsole($"Enter API key for {providerName}: "); + } + else + { + return await RenderAsync("Provide --env-var, pass --stdin, or run interactively to enter a secret without exposing it on the command line.", context, false, cancellationToken).ConfigureAwait(false); + } + + if (string.IsNullOrWhiteSpace(secret)) + { + return await RenderAsync("No API key value was provided.", context, false, cancellationToken).ConfigureAwait(false); + } + + await providerCredentialStore.SetProtectedSecretAsync(providerName, secret, cancellationToken).ConfigureAwait(false); + return await RenderAsync( + $"Stored a protected local credential for provider '{providerName}'.", + context, + cancellationToken).ConfigureAwait(false); + } + + private async Task ExecuteClearKeyAsync(string providerName, CommandExecutionContext context, CancellationToken cancellationToken) + { + var removed = await providerCredentialStore.ClearAsync(providerName, cancellationToken).ConfigureAwait(false); + return await RenderAsync( + removed ? $"Cleared the stored credential reference for provider '{providerName}'." : $"No stored credential reference was found for provider '{providerName}'.", + context, + cancellationToken, + removed).ConfigureAwait(false); + } + + private async Task EnsureProviderExistsAsync(string providerName, CancellationToken cancellationToken) + { + var providers = await providerCatalogService.ListAsync(cancellationToken).ConfigureAwait(false); + if (!providers.Any(entry => string.Equals(entry.ProviderName, providerName, StringComparison.OrdinalIgnoreCase))) + { + throw new InvalidOperationException($"Unknown provider '{providerName}'."); + } + } + + private async Task RenderAsync(string message, CommandExecutionContext context, CancellationToken cancellationToken, bool success = true) + { + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(success, success ? 0 : 1, context.OutputFormat, message, null), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return success ? 0 : 1; + } + + private static string ReadSecretFromConsole(string prompt) + { + Console.Write(prompt); + var builder = new StringBuilder(); + while (true) + { + var key = Console.ReadKey(intercept: true); + if (key.Key == ConsoleKey.Enter) + { + Console.WriteLine(); + return builder.ToString(); + } + + if (key.Key == ConsoleKey.Backspace) + { + if (builder.Length > 0) + { + builder.Length--; + } + + continue; + } + + if (!char.IsControl(key.KeyChar)) + { + builder.Append(key.KeyChar); + } + } + } +} diff --git a/src/SharpClaw.Code.Commands/Handlers/ClearSlashCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/ClearSlashCommandHandler.cs new file mode 100644 index 0000000..5707e37 --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/ClearSlashCommandHandler.cs @@ -0,0 +1,31 @@ +using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Protocol.Commands; + +namespace SharpClaw.Code.Commands; + +/// +/// Clears the REPL screen and transient overrides without touching durable session state. +/// +public sealed class ClearSlashCommandHandler( + ReplInteractionState replInteractionState, + IReplTerminal terminal, + OutputRendererDispatcher outputRendererDispatcher) : ISlashCommandHandler +{ + /// + public string CommandName => "clear"; + + /// + public string Description => "Clears the REPL screen and transient overrides."; + + /// + public async Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) + { + replInteractionState.ClearTransientOverrides(); + terminal.ClearScreen(); + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(true, 0, context.OutputFormat, "Cleared the REPL screen and transient overrides.", null), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return 0; + } +} diff --git a/src/SharpClaw.Code.Commands/Handlers/EvolutionCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/EvolutionCommandHandler.cs new file mode 100644 index 0000000..ca011f5 --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/EvolutionCommandHandler.cs @@ -0,0 +1,166 @@ +using System.CommandLine; +using System.Text.Json; +using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Commands.Options; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Runtime.Abstractions; + +namespace SharpClaw.Code.Commands; + +/// +/// Analyzes and manages guided self-evolution proposals. +/// +public sealed class EvolutionCommandHandler( + IEvolutionProposalService evolutionProposalService, + OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler, ISlashCommandHandler +{ + /// + public string Name => "evolution"; + + /// + public string Description => "Analyzes workspace signals, stores proposals, and applies or rejects them."; + + /// + public string CommandName => Name; + + /// + public Command BuildCommand(GlobalCliOptions globalOptions) + { + var command = new Command(Name, Description); + var analyze = new Command("analyze", "Refreshes durable evolution proposals from workspace signals."); + analyze.SetAction((parseResult, cancellationToken) => ExecuteAnalyzeAsync(globalOptions.Resolve(parseResult), cancellationToken)); + command.Subcommands.Add(analyze); + + var list = new Command("list", "Lists evolution proposals."); + list.SetAction((parseResult, cancellationToken) => ExecuteListAsync(globalOptions.Resolve(parseResult), cancellationToken)); + command.Subcommands.Add(list); + + var show = CreateIdCommand("show", "Shows one evolution proposal.", globalOptions, ExecuteShowAsync); + command.Subcommands.Add(show); + + var apply = CreateIdCommand("apply", "Applies one evolution proposal.", globalOptions, ExecuteApplyAsync); + command.Subcommands.Add(apply); + + var reject = CreateIdCommand("reject", "Rejects one evolution proposal.", globalOptions, ExecuteRejectAsync); + command.Subcommands.Add(reject); + + command.SetAction((parseResult, cancellationToken) => ExecuteListAsync(globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + /// + public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) + { + if (command.Arguments.Length == 0 || string.Equals(command.Arguments[0], "list", StringComparison.OrdinalIgnoreCase)) + { + return ExecuteListAsync(context, cancellationToken); + } + + if (string.Equals(command.Arguments[0], "analyze", StringComparison.OrdinalIgnoreCase)) + { + return ExecuteAnalyzeAsync(context, cancellationToken); + } + + if (command.Arguments.Length >= 2 && string.Equals(command.Arguments[0], "show", StringComparison.OrdinalIgnoreCase)) + { + return ExecuteShowAsync(command.Arguments[1], context, cancellationToken); + } + + if (command.Arguments.Length >= 2 && string.Equals(command.Arguments[0], "apply", StringComparison.OrdinalIgnoreCase)) + { + return ExecuteApplyAsync(command.Arguments[1], context, cancellationToken); + } + + if (command.Arguments.Length >= 2 && string.Equals(command.Arguments[0], "reject", StringComparison.OrdinalIgnoreCase)) + { + return ExecuteRejectAsync(command.Arguments[1], context, cancellationToken); + } + + return RenderAsync("Usage: /evolution [analyze|list|show|apply|reject ]", context, false, cancellationToken); + } + + private Command CreateIdCommand( + string name, + string description, + GlobalCliOptions globalOptions, + Func> action) + { + var command = new Command(name, description); + var idOption = new Option("--id") { Required = true, Description = "Evolution proposal id." }; + command.Options.Add(idOption); + command.SetAction((parseResult, cancellationToken) => action( + parseResult.GetValue(idOption) ?? throw new InvalidOperationException("--id is required."), + globalOptions.Resolve(parseResult), + cancellationToken)); + return command; + } + + private async Task ExecuteAnalyzeAsync(CommandExecutionContext context, CancellationToken cancellationToken) + { + var proposals = await evolutionProposalService.AnalyzeAsync(context.WorkingDirectory, context.SessionId, cancellationToken).ConfigureAwait(false); + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(true, 0, context.OutputFormat, $"{proposals.Count} evolution proposal(s).", JsonSerializer.Serialize(proposals, ProtocolJsonContext.Default.ListEvolutionProposal)), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return 0; + } + + private async Task ExecuteListAsync(CommandExecutionContext context, CancellationToken cancellationToken) + { + var proposals = await evolutionProposalService.ListAsync(context.WorkingDirectory, cancellationToken).ConfigureAwait(false); + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(true, 0, context.OutputFormat, $"{proposals.Count} evolution proposal(s).", JsonSerializer.Serialize(proposals, ProtocolJsonContext.Default.ListEvolutionProposal)), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return 0; + } + + private async Task ExecuteShowAsync(string proposalId, CommandExecutionContext context, CancellationToken cancellationToken) + { + var proposal = await evolutionProposalService.GetAsync(context.WorkingDirectory, proposalId, cancellationToken).ConfigureAwait(false); + if (proposal is null) + { + return await RenderAsync($"Evolution proposal '{proposalId}' was not found.", context, false, cancellationToken).ConfigureAwait(false); + } + + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(true, 0, context.OutputFormat, $"{proposal.Id}: {proposal.Title}", JsonSerializer.Serialize(proposal, ProtocolJsonContext.Default.EvolutionProposal)), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return 0; + } + + private async Task ExecuteApplyAsync(string proposalId, CommandExecutionContext context, CancellationToken cancellationToken) + { + var proposal = await evolutionProposalService + .ApplyAsync(context.WorkingDirectory, proposalId, context.ToRuntimeCommandContext(), cancellationToken) + .ConfigureAwait(false); + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(true, 0, context.OutputFormat, $"Applied evolution proposal '{proposal.Id}' ({proposal.Category}).", JsonSerializer.Serialize(proposal, ProtocolJsonContext.Default.EvolutionProposal)), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return 0; + } + + private async Task ExecuteRejectAsync(string proposalId, CommandExecutionContext context, CancellationToken cancellationToken) + { + var proposal = await evolutionProposalService + .RejectAsync(context.WorkingDirectory, proposalId, context.AgentId ?? "cli", cancellationToken) + .ConfigureAwait(false); + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(true, 0, context.OutputFormat, $"Rejected evolution proposal '{proposal.Id}'.", JsonSerializer.Serialize(proposal, ProtocolJsonContext.Default.EvolutionProposal)), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return 0; + } + + private async Task RenderAsync(string message, CommandExecutionContext context, bool success, CancellationToken cancellationToken) + { + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(success, success ? 0 : 1, context.OutputFormat, message, null), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return success ? 0 : 1; + } +} diff --git a/src/SharpClaw.Code.Commands/Handlers/InitCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/InitCommandHandler.cs new file mode 100644 index 0000000..20773cc --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/InitCommandHandler.cs @@ -0,0 +1,75 @@ +using System.CommandLine; +using System.Text.Json; +using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Commands.Options; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Runtime.Abstractions; + +namespace SharpClaw.Code.Commands; + +/// +/// Bootstraps the workspace-local SharpClaw configuration footprint. +/// +public sealed class InitCommandHandler( + IWorkspaceBootstrapService workspaceBootstrapService, + OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler, ISlashCommandHandler +{ + /// + public string Name => "init"; + + /// + public string Description => "Creates .sharpclaw/config.jsonc and optional commands/skills directories."; + + /// + public string CommandName => Name; + + /// + public Command BuildCommand(GlobalCliOptions globalOptions) + { + var command = new Command(Name, Description); + var forceOption = new Option("--force") { Description = "Overwrite the workspace config file if it already exists." }; + var commandsOption = new Option("--commands") { Description = "Create .sharpclaw/commands." }; + var skillsOption = new Option("--skills") { Description = "Create .sharpclaw/skills." }; + command.Options.Add(forceOption); + command.Options.Add(commandsOption); + command.Options.Add(skillsOption); + command.SetAction((parseResult, cancellationToken) => ExecuteAsync( + globalOptions.Resolve(parseResult), + parseResult.GetValue(forceOption), + parseResult.GetValue(commandsOption), + parseResult.GetValue(skillsOption), + cancellationToken)); + return command; + } + + /// + public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) + { + var force = command.Arguments.Any(static item => string.Equals(item, "force", StringComparison.OrdinalIgnoreCase)); + var includeAll = command.Arguments.Any(static item => string.Equals(item, "all", StringComparison.OrdinalIgnoreCase)); + var includeCommands = includeAll || command.Arguments.Any(static item => string.Equals(item, "commands", StringComparison.OrdinalIgnoreCase)); + var includeSkills = includeAll || command.Arguments.Any(static item => string.Equals(item, "skills", StringComparison.OrdinalIgnoreCase)); + return ExecuteAsync(context, force, includeCommands, includeSkills, cancellationToken); + } + + private async Task ExecuteAsync( + CommandExecutionContext context, + bool force, + bool includeCommandsDirectory, + bool includeSkillsDirectory, + CancellationToken cancellationToken) + { + var result = await workspaceBootstrapService + .InitializeAsync(context.WorkingDirectory, force, includeCommandsDirectory, includeSkillsDirectory, cancellationToken) + .ConfigureAwait(false); + var createdDirectories = result.CreatedDirectories.Length == 0 + ? "none" + : string.Join(", ", result.CreatedDirectories); + var message = $"Initialized SharpClaw workspace config at {result.ConfigPath}. Created directories: {createdDirectories}."; + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(true, 0, context.OutputFormat, message, JsonSerializer.Serialize(result)), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return 0; + } +} diff --git a/src/SharpClaw.Code.Commands/Handlers/ModeSlashCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/ModeSlashCommandHandler.cs index 33c9e8a..55da574 100644 --- a/src/SharpClaw.Code.Commands/Handlers/ModeSlashCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/ModeSlashCommandHandler.cs @@ -15,7 +15,7 @@ public sealed class ModeSlashCommandHandler( public string CommandName => "mode"; /// - public string Description => "Shows or sets build, plan, or spec mode for the REPL session."; + public string Description => "Shows or sets build, plan, spec, or research mode for the REPL session."; /// public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) @@ -31,13 +31,14 @@ public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionC { "plan" => PrimaryMode.Plan, "spec" => PrimaryMode.Spec, + "research" => PrimaryMode.Research, "build" => PrimaryMode.Build, _ => (PrimaryMode?)null, }; if (next is null) { - return RenderAsync("Usage: /mode [build|plan|spec]", context, cancellationToken, success: false); + return RenderAsync("Usage: /mode [build|plan|spec|research]", context, cancellationToken, success: false); } replState.PrimaryModeOverride = next; diff --git a/src/SharpClaw.Code.Commands/Handlers/ModelSlashCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/ModelSlashCommandHandler.cs new file mode 100644 index 0000000..87d6a30 --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/ModelSlashCommandHandler.cs @@ -0,0 +1,25 @@ +using SharpClaw.Code.Commands.Models; + +namespace SharpClaw.Code.Commands; + +/// +/// Provides a singular alias over the models slash command surface. +/// +public sealed class ModelSlashCommandHandler(ModelsCommandHandler modelsCommandHandler) : ISlashCommandHandler +{ + /// + public string CommandName => "model"; + + /// + public string Description => "Alias for /models show|use|clear."; + + /// + public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) + => modelsCommandHandler.ExecuteAsync( + new SlashCommandParseResult( + true, + "models", + command.Arguments.Length == 0 ? ["show"] : command.Arguments), + context, + cancellationToken); +} diff --git a/src/SharpClaw.Code.Commands/Handlers/ModelsCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/ModelsCommandHandler.cs index 25b0d2f..41e4ba4 100644 --- a/src/SharpClaw.Code.Commands/Handlers/ModelsCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/ModelsCommandHandler.cs @@ -4,7 +4,9 @@ using SharpClaw.Code.Commands.Options; using SharpClaw.Code.Providers.Abstractions; using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Enums; using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Runtime.Abstractions; namespace SharpClaw.Code.Commands; @@ -13,6 +15,7 @@ namespace SharpClaw.Code.Commands; /// public sealed class ModelsCommandHandler( IProviderCatalogService providerCatalogService, + ISessionPreferenceService sessionPreferenceService, OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler, ISlashCommandHandler { /// @@ -28,15 +31,58 @@ public sealed class ModelsCommandHandler( public Command BuildCommand(GlobalCliOptions globalOptions) { var command = new Command(Name, Description); - command.SetAction((parseResult, cancellationToken) => ExecuteAsync(globalOptions.Resolve(parseResult), cancellationToken)); + var show = new Command("show", "Shows the active session model preference."); + show.SetAction((parseResult, cancellationToken) => ExecuteShowAsync(globalOptions.Resolve(parseResult), cancellationToken)); + command.Subcommands.Add(show); + + var use = new Command("use", "Persists a session-scoped model preference."); + var modelArgument = new Argument("model") + { + Description = "Provider/model id or configured alias." + }; + use.Arguments.Add(modelArgument); + use.SetAction((parseResult, cancellationToken) => ExecuteUseAsync( + parseResult.GetValue(modelArgument) ?? throw new InvalidOperationException("model is required."), + globalOptions.Resolve(parseResult), + cancellationToken)); + command.Subcommands.Add(use); + + var clear = new Command("clear", "Clears the persisted session model preference."); + clear.SetAction((parseResult, cancellationToken) => ExecuteClearAsync(globalOptions.Resolve(parseResult), cancellationToken)); + command.Subcommands.Add(clear); + + command.SetAction((parseResult, cancellationToken) => ExecuteListAsync(globalOptions.Resolve(parseResult), cancellationToken)); return command; } /// public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) - => ExecuteAsync(context, cancellationToken); + { + if (command.Arguments.Length == 0 || string.Equals(command.Arguments[0], "list", StringComparison.OrdinalIgnoreCase)) + { + return ExecuteListAsync(context, cancellationToken); + } + + if (string.Equals(command.Arguments[0], "show", StringComparison.OrdinalIgnoreCase)) + { + return ExecuteShowAsync(context, cancellationToken); + } + + if (string.Equals(command.Arguments[0], "clear", StringComparison.OrdinalIgnoreCase) + || string.Equals(command.Arguments[0], "reset", StringComparison.OrdinalIgnoreCase)) + { + return ExecuteClearAsync(context, cancellationToken); + } - private async Task ExecuteAsync(CommandExecutionContext context, CancellationToken cancellationToken) + if (string.Equals(command.Arguments[0], "use", StringComparison.OrdinalIgnoreCase) && command.Arguments.Length >= 2) + { + return ExecuteUseAsync(command.Arguments[1], context, cancellationToken); + } + + return RenderAsync("Usage: /models [list|show|use |clear]", context, false, cancellationToken); + } + + private async Task ExecuteListAsync(CommandExecutionContext context, CancellationToken cancellationToken) { var entries = await providerCatalogService.ListAsync(cancellationToken).ConfigureAwait(false); var payload = entries.ToList(); @@ -50,4 +96,67 @@ private async Task ExecuteAsync(CommandExecutionContext context, Cancellati await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return 0; } + + private async Task ExecuteShowAsync(CommandExecutionContext context, CancellationToken cancellationToken) + { + var report = await sessionPreferenceService + .GetPermissionStatusAsync( + context.WorkingDirectory, + context.SessionId, + context.PermissionMode, + context.ApprovalSettings, + context.Model, + cancellationToken) + .ConfigureAwait(false); + var message = string.IsNullOrWhiteSpace(report.EffectiveModel) + ? "No session model preference is currently persisted." + : $"Active session model preference: {report.EffectiveModel}."; + return await RenderAsync( + new CommandResult( + true, + 0, + context.OutputFormat, + message, + JsonSerializer.Serialize(report, ProtocolJsonContext.Default.PermissionStatusReport)), + context, + cancellationToken).ConfigureAwait(false); + } + + private async Task ExecuteUseAsync(string model, CommandExecutionContext context, CancellationToken cancellationToken) + { + var preference = await sessionPreferenceService + .SetModelPreferenceAsync(context.WorkingDirectory, context.SessionId, model, cancellationToken) + .ConfigureAwait(false); + return await RenderAsync( + new CommandResult( + true, + 0, + context.OutputFormat, + $"Persisted session model preference '{preference.Model}'.", + JsonSerializer.Serialize(preference, ProtocolJsonContext.Default.SessionModelPreference)), + context, + cancellationToken).ConfigureAwait(false); + } + + private async Task ExecuteClearAsync(CommandExecutionContext context, CancellationToken cancellationToken) + { + var removed = await sessionPreferenceService + .ClearModelPreferenceAsync(context.WorkingDirectory, context.SessionId, cancellationToken) + .ConfigureAwait(false); + return await RenderAsync( + new CommandResult( + removed, + removed ? 0 : 1, + context.OutputFormat, + removed ? "Cleared the persisted session model preference." : "No persisted session model preference was found.", + null), + context, + cancellationToken).ConfigureAwait(false); + } + + private async Task RenderAsync(CommandResult result, CommandExecutionContext context, CancellationToken cancellationToken) + { + await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return result.ExitCode; + } } diff --git a/src/SharpClaw.Code.Commands/Handlers/NewSessionSlashCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/NewSessionSlashCommandHandler.cs new file mode 100644 index 0000000..4ba6125 --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/NewSessionSlashCommandHandler.cs @@ -0,0 +1,49 @@ +using System.Text.Json; +using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Runtime.Abstractions; + +namespace SharpClaw.Code.Commands; + +/// +/// Creates and attaches a fresh session for the current workspace. +/// +public sealed class NewSessionSlashCommandHandler( + IConversationRuntime conversationRuntime, + IRuntimeCommandService runtimeCommandService, + ReplInteractionState replInteractionState, + OutputRendererDispatcher outputRendererDispatcher) : ISlashCommandHandler +{ + /// + public string CommandName => "new"; + + /// + public string Description => "Creates and attaches a fresh workspace session."; + + /// + public async Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) + { + var session = await conversationRuntime + .CreateSessionAsync( + context.WorkingDirectory, + replInteractionState.PermissionModeOverride ?? context.PermissionMode, + context.OutputFormat, + cancellationToken) + .ConfigureAwait(false); + replInteractionState.ClearTransientOverrides(); + await runtimeCommandService + .AttachSessionAsync(session.Id, context.ToRuntimeCommandContext(), cancellationToken) + .ConfigureAwait(false); + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult( + true, + 0, + context.OutputFormat, + $"Created and attached session '{session.Id}'.", + JsonSerializer.Serialize(session, ProtocolJsonContext.Default.ConversationSession)), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return 0; + } +} diff --git a/src/SharpClaw.Code.Commands/Handlers/PermissionsCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/PermissionsCommandHandler.cs new file mode 100644 index 0000000..0c65103 --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/PermissionsCommandHandler.cs @@ -0,0 +1,315 @@ +using System.CommandLine; +using System.Text.Json; +using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Commands.Options; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Runtime.Abstractions; + +namespace SharpClaw.Code.Commands; + +/// +/// Shows and persists durable session permission settings, approval defaults, and trusted sources. +/// +public sealed class PermissionsCommandHandler( + ISessionPreferenceService sessionPreferenceService, + ReplInteractionState replInteractionState, + OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler, ISlashCommandHandler +{ + /// + public string Name => "permissions"; + + /// + public string Description => "Shows or persists session permission mode, approvals, and trusted MCP/plugin sources."; + + /// + public string CommandName => Name; + + /// + public Command BuildCommand(GlobalCliOptions globalOptions) + { + var command = new Command(Name, Description); + + var show = new Command("show", "Shows the effective durable permission snapshot."); + show.SetAction((parseResult, cancellationToken) => ExecuteShowAsync(globalOptions.Resolve(parseResult), cancellationToken)); + command.Subcommands.Add(show); + + var mode = new Command("mode", "Shows or sets the durable session permission mode."); + var modeSet = new Command("set", "Persists the session permission mode."); + var modeArgument = new Argument("mode") { Description = "readOnly, workspaceWrite, or dangerFullAccess." }; + modeSet.Arguments.Add(modeArgument); + modeSet.SetAction((parseResult, cancellationToken) => ExecuteSetModeAsync( + parseResult.GetValue(modeArgument) ?? throw new InvalidOperationException("mode is required."), + globalOptions.Resolve(parseResult), + cancellationToken)); + mode.Subcommands.Add(modeSet); + mode.SetAction((parseResult, cancellationToken) => ExecuteShowAsync(globalOptions.Resolve(parseResult), cancellationToken)); + command.Subcommands.Add(mode); + + var approvals = new Command("approvals", "Shows or sets durable auto-approval settings."); + var approvalsSet = new Command("set", "Persists approval scopes and optional budget."); + var scopesArgument = new Argument("scopes") { Description = "Comma-separated scopes: tool,file,shell,network,session,promptRead,all,none." }; + var budgetOption = new Option("--budget") { Description = "Optional auto-approval budget." }; + approvalsSet.Arguments.Add(scopesArgument); + approvalsSet.Options.Add(budgetOption); + approvalsSet.SetAction((parseResult, cancellationToken) => ExecuteSetApprovalsAsync( + parseResult.GetValue(scopesArgument) ?? throw new InvalidOperationException("scopes are required."), + parseResult.GetValue(budgetOption), + globalOptions.Resolve(parseResult), + cancellationToken)); + approvals.Subcommands.Add(approvalsSet); + + var approvalsClear = new Command("clear", "Clears durable auto-approval settings."); + approvalsClear.SetAction((parseResult, cancellationToken) => ExecuteClearApprovalsAsync(globalOptions.Resolve(parseResult), cancellationToken)); + approvals.Subcommands.Add(approvalsClear); + + var approvalsShow = new Command("show", "Shows durable auto-approval settings."); + approvalsShow.SetAction((parseResult, cancellationToken) => ExecuteShowAsync(globalOptions.Resolve(parseResult), cancellationToken)); + approvals.Subcommands.Add(approvalsShow); + approvals.SetAction((parseResult, cancellationToken) => ExecuteShowAsync(globalOptions.Resolve(parseResult), cancellationToken)); + command.Subcommands.Add(approvals); + + var trust = new Command("trust", "Lists or modifies durable trusted plugin and MCP sources."); + var trustList = new Command("list", "Lists trusted plugin and MCP sources."); + trustList.SetAction((parseResult, cancellationToken) => ExecuteShowAsync(globalOptions.Resolve(parseResult), cancellationToken)); + trust.Subcommands.Add(trustList); + + var trustGrant = new Command("grant", "Grants durable trust to one plugin or MCP server."); + var kindArgument = new Argument("kind") { Description = "plugin or mcp." }; + var nameArgument = new Argument("name") { Description = "Plugin id or MCP server name." }; + trustGrant.Arguments.Add(kindArgument); + trustGrant.Arguments.Add(nameArgument); + trustGrant.SetAction((parseResult, cancellationToken) => ExecuteTrustAsync( + parseResult.GetValue(kindArgument) ?? throw new InvalidOperationException("kind is required."), + parseResult.GetValue(nameArgument) ?? throw new InvalidOperationException("name is required."), + grant: true, + globalOptions.Resolve(parseResult), + cancellationToken)); + trust.Subcommands.Add(trustGrant); + + var trustRevoke = new Command("revoke", "Revokes durable trust from one plugin or MCP server."); + var revokeKindArgument = new Argument("kind") { Description = "plugin or mcp." }; + var revokeNameArgument = new Argument("name") { Description = "Plugin id or MCP server name." }; + trustRevoke.Arguments.Add(revokeKindArgument); + trustRevoke.Arguments.Add(revokeNameArgument); + trustRevoke.SetAction((parseResult, cancellationToken) => ExecuteTrustAsync( + parseResult.GetValue(revokeKindArgument) ?? throw new InvalidOperationException("kind is required."), + parseResult.GetValue(revokeNameArgument) ?? throw new InvalidOperationException("name is required."), + grant: false, + globalOptions.Resolve(parseResult), + cancellationToken)); + trust.Subcommands.Add(trustRevoke); + trust.SetAction((parseResult, cancellationToken) => ExecuteShowAsync(globalOptions.Resolve(parseResult), cancellationToken)); + command.Subcommands.Add(trust); + + command.SetAction((parseResult, cancellationToken) => ExecuteShowAsync(globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + /// + public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) + { + if (command.Arguments.Length == 0 || string.Equals(command.Arguments[0], "show", StringComparison.OrdinalIgnoreCase)) + { + return ExecuteShowAsync(context, cancellationToken); + } + + if (string.Equals(command.Arguments[0], "mode", StringComparison.OrdinalIgnoreCase)) + { + if (command.Arguments.Length >= 3 && string.Equals(command.Arguments[1], "set", StringComparison.OrdinalIgnoreCase)) + { + return ExecuteSetModeAsync(command.Arguments[2], context, cancellationToken); + } + + return RenderAsync("Usage: /permissions mode set ", context, false, cancellationToken); + } + + if (string.Equals(command.Arguments[0], "approvals", StringComparison.OrdinalIgnoreCase)) + { + if (command.Arguments.Length == 1 || string.Equals(command.Arguments[1], "show", StringComparison.OrdinalIgnoreCase)) + { + return ExecuteShowAsync(context, cancellationToken); + } + + if (string.Equals(command.Arguments[1], "clear", StringComparison.OrdinalIgnoreCase) + || string.Equals(command.Arguments[1], "reset", StringComparison.OrdinalIgnoreCase)) + { + return ExecuteClearApprovalsAsync(context, cancellationToken); + } + + if (string.Equals(command.Arguments[1], "set", StringComparison.OrdinalIgnoreCase) && command.Arguments.Length >= 3) + { + var budget = command.Arguments.Length >= 4 && int.TryParse(command.Arguments[3], out var parsedBudget) + ? parsedBudget + : (int?)null; + return ExecuteSetApprovalsAsync(command.Arguments[2], budget, context, cancellationToken); + } + + return RenderAsync("Usage: /permissions approvals [show|set [budget]|clear]", context, false, cancellationToken); + } + + if (string.Equals(command.Arguments[0], "trust", StringComparison.OrdinalIgnoreCase)) + { + if (command.Arguments.Length == 1 || string.Equals(command.Arguments[1], "list", StringComparison.OrdinalIgnoreCase)) + { + return ExecuteShowAsync(context, cancellationToken); + } + + if (command.Arguments.Length >= 4 + && (string.Equals(command.Arguments[1], "grant", StringComparison.OrdinalIgnoreCase) + || string.Equals(command.Arguments[1], "revoke", StringComparison.OrdinalIgnoreCase))) + { + return ExecuteTrustAsync( + command.Arguments[2], + command.Arguments[3], + string.Equals(command.Arguments[1], "grant", StringComparison.OrdinalIgnoreCase), + context, + cancellationToken); + } + + return RenderAsync("Usage: /permissions trust [list|grant |revoke ]", context, false, cancellationToken); + } + + return RenderAsync("Usage: /permissions [show|mode|approvals|trust]", context, false, cancellationToken); + } + + private async Task ExecuteShowAsync(CommandExecutionContext context, CancellationToken cancellationToken) + { + var report = await sessionPreferenceService + .GetPermissionStatusAsync( + context.WorkingDirectory, + context.SessionId, + replInteractionState.PermissionModeOverride ?? context.PermissionMode, + context.ApprovalSettings, + context.Model, + cancellationToken) + .ConfigureAwait(false); + var message = $"Permission mode: {report.PermissionMode}. Auto-approvals: {ApprovalSettingsText.RenderSummary(report.ApprovalSettings)}. Trusted sources: {report.TrustedSources.Length}. Attached session: {report.AttachedSessionId ?? "none"}."; + if (!string.IsNullOrWhiteSpace(report.EffectiveModel)) + { + message += $" Model: {report.EffectiveModel}."; + } + + if (replInteractionState.PermissionModeOverride is not null && replInteractionState.PermissionModeOverride != report.PermissionMode) + { + message += $" REPL override: {replInteractionState.PermissionModeOverride}."; + } + + return await RenderAsync( + new CommandResult( + true, + 0, + context.OutputFormat, + message, + JsonSerializer.Serialize(report, ProtocolJsonContext.Default.PermissionStatusReport)), + context, + cancellationToken).ConfigureAwait(false); + } + + private async Task ExecuteSetModeAsync(string permissionModeText, CommandExecutionContext context, CancellationToken cancellationToken) + { + var parsedMode = ParsePermissionMode(permissionModeText); + await sessionPreferenceService + .SetPreferredPermissionModeAsync(context.WorkingDirectory, context.SessionId, parsedMode, cancellationToken) + .ConfigureAwait(false); + replInteractionState.PermissionModeOverride = parsedMode; + return await RenderAsync($"Persisted session permission mode '{parsedMode}'.", context, cancellationToken).ConfigureAwait(false); + } + + private async Task ExecuteSetApprovalsAsync(string scopesText, int? budget, CommandExecutionContext context, CancellationToken cancellationToken) + { + var settings = ApprovalSettingsText.Parse(scopesText, budget) ?? ApprovalSettings.Empty; + var persisted = await sessionPreferenceService + .SetApprovalSettingsAsync(context.WorkingDirectory, context.SessionId, settings, cancellationToken) + .ConfigureAwait(false); + replInteractionState.ApprovalSettingsOverride = null; + return await RenderAsync( + $"Persisted session auto-approvals: {ApprovalSettingsText.RenderSummary(persisted)}.", + context, + cancellationToken).ConfigureAwait(false); + } + + private async Task ExecuteClearApprovalsAsync(CommandExecutionContext context, CancellationToken cancellationToken) + { + var cleared = await sessionPreferenceService + .ClearApprovalSettingsAsync(context.WorkingDirectory, context.SessionId, cancellationToken) + .ConfigureAwait(false); + replInteractionState.ApprovalSettingsOverride = null; + return await RenderAsync( + cleared ? "Cleared durable session auto-approval settings." : "No durable session auto-approval settings were found.", + context, + cancellationToken, + cleared).ConfigureAwait(false); + } + + private async Task ExecuteTrustAsync( + string kindText, + string name, + bool grant, + CommandExecutionContext context, + CancellationToken cancellationToken) + { + var kind = ParseTrustedSourceKind(kindText); + var report = grant + ? await sessionPreferenceService + .GrantTrustAsync( + context.WorkingDirectory, + context.SessionId, + kind, + name, + context.PermissionMode, + context.ApprovalSettings, + context.Model, + cancellationToken) + .ConfigureAwait(false) + : await sessionPreferenceService + .RevokeTrustAsync( + context.WorkingDirectory, + context.SessionId, + kind, + name, + context.PermissionMode, + context.ApprovalSettings, + context.Model, + cancellationToken) + .ConfigureAwait(false); + var action = grant ? "Granted" : "Revoked"; + return await RenderAsync( + new CommandResult( + true, + 0, + context.OutputFormat, + $"{action} durable trust for {kind.ToString().ToLowerInvariant()} '{name.Trim()}'.", + JsonSerializer.Serialize(report, ProtocolJsonContext.Default.PermissionStatusReport)), + context, + cancellationToken).ConfigureAwait(false); + } + + private async Task RenderAsync(string message, CommandExecutionContext context, CancellationToken cancellationToken, bool success = true) + => await RenderAsync(new CommandResult(success, success ? 0 : 1, context.OutputFormat, message, null), context, cancellationToken).ConfigureAwait(false); + + private async Task RenderAsync(CommandResult result, CommandExecutionContext context, CancellationToken cancellationToken) + { + await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return result.ExitCode; + } + + private static PermissionMode ParsePermissionMode(string value) + => value.Trim().ToLowerInvariant() switch + { + "readonly" or "read-only" => PermissionMode.ReadOnly, + "workspacewrite" or "workspace-write" or "prompt" or "autoapprovesafe" or "auto-approve-safe" => PermissionMode.WorkspaceWrite, + "dangerfullaccess" or "danger-full-access" or "fulltrust" or "full-trust" => PermissionMode.DangerFullAccess, + _ => throw new InvalidOperationException($"Unsupported permission mode '{value}'.") + }; + + private static TrustedSourceKind ParseTrustedSourceKind(string value) + => value.Trim().ToLowerInvariant() switch + { + "plugin" => TrustedSourceKind.Plugin, + "mcp" => TrustedSourceKind.Mcp, + _ => throw new InvalidOperationException($"Unsupported trusted source kind '{value}'. Use plugin or mcp.") + }; +} diff --git a/src/SharpClaw.Code.Commands/Handlers/ResearchCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/ResearchCommandHandler.cs new file mode 100644 index 0000000..236c8eb --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/ResearchCommandHandler.cs @@ -0,0 +1,107 @@ +using System.CommandLine; +using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Commands.Options; +using SharpClaw.Code.Providers.Models; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Runtime.Abstractions; + +namespace SharpClaw.Code.Commands; + +/// +/// Executes a prompt in read-only research mode. +/// +public sealed class ResearchCommandHandler( + IResearchWorkflowService researchWorkflowService, + OutputRendererDispatcher outputRendererDispatcher, + ICliInvocationEnvironment cliInvocationEnvironment) : ICommandHandler, ISlashCommandHandler +{ + /// + public string Name => "research"; + + /// + public string Description => "Runs a prompt in citation-oriented read-only research mode."; + + /// + public string CommandName => Name; + + /// + public Command BuildCommand(GlobalCliOptions globalOptions) + { + var command = new Command(Name, Description); + var promptArgument = new Argument("prompt") + { + Arity = ArgumentArity.ZeroOrMore, + Description = "Research prompt text." + }; + command.Arguments.Add(promptArgument); + command.SetAction((parseResult, cancellationToken) => ExecuteAsync( + globalOptions.Resolve(parseResult), + parseResult.GetValue(promptArgument) ?? [], + cancellationToken)); + return command; + } + + /// + public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) + => ExecuteAsync(context, command.Arguments, cancellationToken); + + private async Task ExecuteAsync( + CommandExecutionContext context, + IReadOnlyList promptTokens, + CancellationToken cancellationToken) + { + var prompt = await BuildPromptAsync(promptTokens, cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(prompt)) + { + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(false, 1, context.OutputFormat, "No research prompt text was provided.", null), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return 1; + } + + var isInteractive = !cliInvocationEnvironment.IsInputRedirected + && !cliInvocationEnvironment.IsOutputRedirected + && context.OutputFormat == OutputFormat.Text; + + try + { + var result = await researchWorkflowService + .ExecuteAsync( + prompt, + context.ToRuntimeCommandContext( + isInteractive: isInteractive, + primaryModeOverride: PrimaryMode.Research, + permissionModeOverride: PermissionMode.ReadOnly), + cancellationToken) + .ConfigureAwait(false); + await outputRendererDispatcher.RenderTurnExecutionResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return 0; + } + catch (ProviderExecutionException exception) + { + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(false, 1, context.OutputFormat, $"Provider failure ({exception.Kind}): {exception.Message}", null), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return 1; + } + } + + private async Task BuildPromptAsync(IReadOnlyList promptTokens, CancellationToken cancellationToken) + { + var promptText = string.Join(' ', promptTokens).Trim(); + var stdinText = cliInvocationEnvironment.IsInputRedirected + ? (await cliInvocationEnvironment.ReadStandardInputToEndAsync(cancellationToken).ConfigureAwait(false)).Trim() + : string.Empty; + if (string.IsNullOrWhiteSpace(stdinText)) + { + return promptText; + } + + return string.IsNullOrWhiteSpace(promptText) + ? stdinText + : $"Piped input:{Environment.NewLine}{stdinText}{Environment.NewLine}{Environment.NewLine}Research request:{Environment.NewLine}{promptText}"; + } +} diff --git a/src/SharpClaw.Code.Commands/Handlers/ResumeSlashCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/ResumeSlashCommandHandler.cs new file mode 100644 index 0000000..7a9dedd --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/ResumeSlashCommandHandler.cs @@ -0,0 +1,40 @@ +using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Runtime.Abstractions; + +namespace SharpClaw.Code.Commands; + +/// +/// Attaches an existing session by id and clears transient REPL overrides. +/// +public sealed class ResumeSlashCommandHandler( + IRuntimeCommandService runtimeCommandService, + ReplInteractionState replInteractionState, + OutputRendererDispatcher outputRendererDispatcher) : ISlashCommandHandler +{ + /// + public string CommandName => "resume"; + + /// + public string Description => "Alias for /session attach ."; + + /// + public async Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) + { + if (command.Arguments.Length == 0) + { + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(false, 1, context.OutputFormat, "Usage: /resume ", null), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return 1; + } + + replInteractionState.ClearTransientOverrides(); + var result = await runtimeCommandService + .AttachSessionAsync(command.Arguments[0], context.ToRuntimeCommandContext(), cancellationToken) + .ConfigureAwait(false); + await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return result.ExitCode; + } +} diff --git a/src/SharpClaw.Code.Commands/Handlers/ScheduleCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/ScheduleCommandHandler.cs new file mode 100644 index 0000000..e346935 --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/ScheduleCommandHandler.cs @@ -0,0 +1,319 @@ +using System.CommandLine; +using System.Text.Json; +using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Commands.Options; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Runtime.Abstractions; + +namespace SharpClaw.Code.Commands; + +/// +/// Manages durable scheduled prompts for the current workspace. +/// +public sealed class ScheduleCommandHandler( + IScheduledPromptService scheduledPromptService, + OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler, ISlashCommandHandler +{ + /// + public string Name => "schedule"; + + /// + public string Description => "Lists, persists, and runs workspace scheduled prompts."; + + /// + public string CommandName => Name; + + /// + public Command BuildCommand(GlobalCliOptions globalOptions) + { + var command = new Command(Name, Description); + + var list = new Command("list", "Lists schedules for the workspace."); + list.SetAction((parseResult, cancellationToken) => ExecuteListAsync(globalOptions.Resolve(parseResult), cancellationToken)); + command.Subcommands.Add(list); + + var add = CreateUpsertCommand("add", "Adds a schedule.", globalOptions, isUpdate: false); + command.Subcommands.Add(add); + + var update = CreateUpsertCommand("update", "Updates a schedule.", globalOptions, isUpdate: true); + command.Subcommands.Add(update); + + command.Subcommands.Add(CreateIdCommand("remove", "Removes a schedule.", globalOptions, ExecuteRemoveAsync)); + command.Subcommands.Add(CreateIdCommand("pause", "Pauses a schedule.", globalOptions, (id, context, ct) => ExecuteSetEnabledAsync(id, context, enabled: false, ct))); + command.Subcommands.Add(CreateIdCommand("resume", "Resumes a schedule.", globalOptions, (id, context, ct) => ExecuteSetEnabledAsync(id, context, enabled: true, ct))); + command.Subcommands.Add(CreateIdCommand("run", "Runs a schedule immediately.", globalOptions, ExecuteRunAsync)); + + command.SetAction((parseResult, cancellationToken) => ExecuteListAsync(globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + /// + public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) + { + if (command.Arguments.Length == 0 || string.Equals(command.Arguments[0], "list", StringComparison.OrdinalIgnoreCase)) + { + return ExecuteListAsync(context, cancellationToken); + } + + if (command.Arguments.Length >= 2 && string.Equals(command.Arguments[0], "run", StringComparison.OrdinalIgnoreCase)) + { + return ExecuteRunAsync(command.Arguments[1], context, cancellationToken); + } + + if (command.Arguments.Length >= 2 && string.Equals(command.Arguments[0], "remove", StringComparison.OrdinalIgnoreCase)) + { + return ExecuteRemoveAsync(command.Arguments[1], context, cancellationToken); + } + + if (command.Arguments.Length >= 2 && string.Equals(command.Arguments[0], "pause", StringComparison.OrdinalIgnoreCase)) + { + return ExecuteSetEnabledAsync(command.Arguments[1], context, enabled: false, cancellationToken); + } + + if (command.Arguments.Length >= 2 && string.Equals(command.Arguments[0], "resume", StringComparison.OrdinalIgnoreCase)) + { + return ExecuteSetEnabledAsync(command.Arguments[1], context, enabled: true, cancellationToken); + } + + if (string.Equals(command.Arguments[0], "add", StringComparison.OrdinalIgnoreCase) && command.Arguments.Length >= 4) + { + var sessionTarget = command.Arguments.Length >= 7 ? command.Arguments[6] : "attached"; + return ExecuteSaveAsync( + scheduleId: null, + name: command.Arguments[1], + prompt: command.Arguments[3], + cron: command.Arguments[2], + primaryMode: command.Arguments.Length >= 5 ? ParsePrimaryMode(command.Arguments[4]) : PrimaryMode.Build, + modelOverride: null, + permissionMode: command.Arguments.Length >= 6 ? ParsePermissionMode(command.Arguments[5]) : PermissionMode.WorkspaceWrite, + approvalSettings: null, + sessionTarget: ParseSessionTarget(sessionTarget), + context: context, + cancellationToken: cancellationToken).AsTask(); + } + + if (string.Equals(command.Arguments[0], "update", StringComparison.OrdinalIgnoreCase) && command.Arguments.Length >= 5) + { + var sessionTarget = command.Arguments.Length >= 8 ? command.Arguments[7] : "attached"; + return ExecuteSaveAsync( + scheduleId: command.Arguments[1], + name: command.Arguments[2], + prompt: command.Arguments[4], + cron: command.Arguments[3], + primaryMode: command.Arguments.Length >= 6 ? ParsePrimaryMode(command.Arguments[5]) : PrimaryMode.Build, + modelOverride: null, + permissionMode: command.Arguments.Length >= 7 ? ParsePermissionMode(command.Arguments[6]) : PermissionMode.WorkspaceWrite, + approvalSettings: null, + sessionTarget: ParseSessionTarget(sessionTarget), + context: context, + cancellationToken: cancellationToken).AsTask(); + } + + return RenderAsync("Usage: /schedule [list|add [mode] [permissionMode] [sessionTarget]|update [mode] [permissionMode] [sessionTarget]|run|pause|resume|remove ]", context, false, cancellationToken); + } + + private Command CreateUpsertCommand(string name, string description, GlobalCliOptions globalOptions, bool isUpdate) + { + var command = new Command(name, description); + var idOption = new Option("--id") { Description = "Schedule id." }; + var nameOption = new Option("--name") { Required = true, Description = "Schedule name." }; + var promptOption = new Option("--prompt") { Required = true, Description = "Prompt text to execute." }; + var cronOption = new Option("--cron") { Required = true, Description = "Cron expression or @hourly/@daily/@weekly." }; + var primaryModeOption = new Option("--primary-mode") { DefaultValueFactory = _ => "build", Description = "build, plan, spec, or research." }; + var modelOption = new Option("--model") { Description = "Optional model override." }; + var permissionModeOption = new Option("--permission-mode") { DefaultValueFactory = _ => "workspaceWrite", Description = "readOnly, workspaceWrite, or dangerFullAccess." }; + var autoApproveOption = new Option("--auto-approve") { Description = "Optional durable auto-approval scopes." }; + var autoApproveBudgetOption = new Option("--auto-approve-budget") { Description = "Optional durable auto-approval budget." }; + var sessionTargetOption = new Option("--session-target") { DefaultValueFactory = _ => "attached", Description = "new, attached, or an explicit session id." }; + + if (isUpdate) + { + idOption.Required = true; + command.Options.Add(idOption); + } + + command.Options.Add(nameOption); + command.Options.Add(promptOption); + command.Options.Add(cronOption); + command.Options.Add(primaryModeOption); + command.Options.Add(modelOption); + command.Options.Add(permissionModeOption); + command.Options.Add(autoApproveOption); + command.Options.Add(autoApproveBudgetOption); + command.Options.Add(sessionTargetOption); + + command.SetAction((parseResult, cancellationToken) => ExecuteSaveAsync( + scheduleId: isUpdate ? parseResult.GetValue(idOption) : null, + name: parseResult.GetValue(nameOption) ?? throw new InvalidOperationException("--name is required."), + prompt: parseResult.GetValue(promptOption) ?? throw new InvalidOperationException("--prompt is required."), + cron: parseResult.GetValue(cronOption) ?? throw new InvalidOperationException("--cron is required."), + primaryMode: ParsePrimaryMode(parseResult.GetValue(primaryModeOption) ?? "build"), + modelOverride: parseResult.GetValue(modelOption), + permissionMode: ParsePermissionMode(parseResult.GetValue(permissionModeOption) ?? "workspaceWrite"), + approvalSettings: ApprovalSettingsText.Parse(parseResult.GetValue(autoApproveOption), parseResult.GetValue(autoApproveBudgetOption)), + sessionTarget: ParseSessionTarget(parseResult.GetValue(sessionTargetOption) ?? "attached"), + context: globalOptions.Resolve(parseResult), + cancellationToken: cancellationToken).AsTask()); + return command; + } + + private Command CreateIdCommand( + string name, + string description, + GlobalCliOptions globalOptions, + Func> action) + { + var command = new Command(name, description); + var idOption = new Option("--id") { Required = true, Description = "Schedule id." }; + command.Options.Add(idOption); + command.SetAction((parseResult, cancellationToken) => action( + parseResult.GetValue(idOption) ?? throw new InvalidOperationException("--id is required."), + globalOptions.Resolve(parseResult), + cancellationToken)); + return command; + } + + private async Task ExecuteListAsync(CommandExecutionContext context, CancellationToken cancellationToken) + { + var schedules = await scheduledPromptService.ListAsync(context.WorkingDirectory, cancellationToken).ConfigureAwait(false); + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(true, 0, context.OutputFormat, $"{schedules.Count} scheduled prompt(s).", JsonSerializer.Serialize(schedules, ProtocolJsonContext.Default.ListScheduledPromptDefinition)), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return 0; + } + + private async ValueTask ExecuteSaveAsync( + string? scheduleId, + string name, + string prompt, + string cron, + PrimaryMode primaryMode, + string? modelOverride, + PermissionMode permissionMode, + ApprovalSettings? approvalSettings, + ScheduledPromptSessionTarget sessionTarget, + CommandExecutionContext context, + CancellationToken cancellationToken) + { + ScheduledPromptDefinition definition; + if (string.IsNullOrWhiteSpace(scheduleId)) + { + definition = new ScheduledPromptDefinition( + Id: CreateScheduleId(), + WorkspaceRoot: context.WorkingDirectory, + Name: name, + Prompt: prompt, + Cron: cron, + PrimaryMode: primaryMode, + ModelOverride: modelOverride, + PermissionMode: permissionMode, + ApprovalSettings: approvalSettings, + SessionTarget: sessionTarget, + Enabled: true, + LastRunUtc: null, + NextRunUtc: null, + LastOutcome: null); + } + else + { + var existing = await scheduledPromptService.GetAsync(context.WorkingDirectory, scheduleId, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Scheduled prompt '{scheduleId}' was not found."); + definition = existing with + { + Name = name, + Prompt = prompt, + Cron = cron, + PrimaryMode = primaryMode, + ModelOverride = modelOverride, + PermissionMode = permissionMode, + ApprovalSettings = approvalSettings, + SessionTarget = sessionTarget, + }; + } + + var saved = await scheduledPromptService.SaveAsync(context.WorkingDirectory, definition, cancellationToken).ConfigureAwait(false); + var message = string.IsNullOrWhiteSpace(scheduleId) + ? $"Added scheduled prompt '{saved.Name}' ({saved.Id})." + : $"Updated scheduled prompt '{saved.Name}' ({saved.Id})."; + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(true, 0, context.OutputFormat, $"{message} Next run: {saved.NextRunUtc?.ToString("O") ?? "paused"}.", JsonSerializer.Serialize(saved, ProtocolJsonContext.Default.ScheduledPromptDefinition)), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return 0; + } + + private async Task ExecuteRemoveAsync(string scheduleId, CommandExecutionContext context, CancellationToken cancellationToken) + { + var removed = await scheduledPromptService.RemoveAsync(context.WorkingDirectory, scheduleId, cancellationToken).ConfigureAwait(false); + return await RenderAsync( + removed ? $"Removed scheduled prompt '{scheduleId}'." : $"Scheduled prompt '{scheduleId}' was not found.", + context, + removed, + cancellationToken).ConfigureAwait(false); + } + + private async Task ExecuteSetEnabledAsync(string scheduleId, CommandExecutionContext context, bool enabled, CancellationToken cancellationToken) + { + var updated = await scheduledPromptService.SetEnabledAsync(context.WorkingDirectory, scheduleId, enabled, cancellationToken).ConfigureAwait(false); + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(true, 0, context.OutputFormat, $"{(enabled ? "Resumed" : "Paused")} scheduled prompt '{updated.Name}'.", JsonSerializer.Serialize(updated, ProtocolJsonContext.Default.ScheduledPromptDefinition)), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return 0; + } + + private async Task ExecuteRunAsync(string scheduleId, CommandExecutionContext context, CancellationToken cancellationToken) + { + var report = await scheduledPromptService.RunAsync(context.WorkingDirectory, scheduleId, context.ToRuntimeCommandContext(isInteractive: false), cancellationToken).ConfigureAwait(false); + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(report.Succeeded, report.Succeeded ? 0 : 1, context.OutputFormat, report.Message, JsonSerializer.Serialize(report, ProtocolJsonContext.Default.ScheduledPromptRunReport)), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return report.Succeeded ? 0 : 1; + } + + private async Task RenderAsync(string message, CommandExecutionContext context, bool success, CancellationToken cancellationToken) + { + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(success, success ? 0 : 1, context.OutputFormat, message, null), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return success ? 0 : 1; + } + + private static PrimaryMode ParsePrimaryMode(string value) + => value.Trim().ToLowerInvariant() switch + { + "plan" => PrimaryMode.Plan, + "spec" => PrimaryMode.Spec, + "research" => PrimaryMode.Research, + _ => PrimaryMode.Build, + }; + + private static PermissionMode ParsePermissionMode(string value) + => value.Trim().ToLowerInvariant() switch + { + "readonly" or "read-only" => PermissionMode.ReadOnly, + "workspacewrite" or "workspace-write" or "prompt" or "autoapprovesafe" or "auto-approve-safe" => PermissionMode.WorkspaceWrite, + "dangerfullaccess" or "danger-full-access" or "fulltrust" or "full-trust" => PermissionMode.DangerFullAccess, + _ => PermissionMode.WorkspaceWrite, + }; + + private static ScheduledPromptSessionTarget ParseSessionTarget(string value) + => value.Trim().ToLowerInvariant() switch + { + "new" => new ScheduledPromptSessionTarget(ScheduledPromptSessionTargetKind.New), + "attached" => new ScheduledPromptSessionTarget(ScheduledPromptSessionTargetKind.Attached), + _ => new ScheduledPromptSessionTarget(ScheduledPromptSessionTargetKind.Explicit, value.Trim()), + }; + + private static string CreateScheduleId() + { + var value = $"schedule-{Guid.NewGuid():N}"; + return value[..Math.Min(value.Length, 21)]; + } +} diff --git a/src/SharpClaw.Code.Commands/Models/CommandExecutionContext.cs b/src/SharpClaw.Code.Commands/Models/CommandExecutionContext.cs index c9a6038..03b55c8 100644 --- a/src/SharpClaw.Code.Commands/Models/CommandExecutionContext.cs +++ b/src/SharpClaw.Code.Commands/Models/CommandExecutionContext.cs @@ -32,18 +32,20 @@ public sealed record CommandExecutionContext( /// /// Whether the current caller can participate in approval prompts. /// Optional primary-mode override. + /// Optional permission-mode override. /// Optional agent id override. /// Optional bounded auto-approval override. /// The runtime command context. public RuntimeCommandContext ToRuntimeCommandContext( bool isInteractive = true, PrimaryMode? primaryModeOverride = null, + PermissionMode? permissionModeOverride = null, string? agentIdOverride = null, ApprovalSettings? approvalSettingsOverride = null) => new( WorkingDirectory, Model, - PermissionMode, + permissionModeOverride ?? PermissionMode, OutputFormat, primaryModeOverride ?? PrimaryMode, SessionId, diff --git a/src/SharpClaw.Code.Commands/Options/GlobalCliOptions.cs b/src/SharpClaw.Code.Commands/Options/GlobalCliOptions.cs index 07d3470..c624300 100644 --- a/src/SharpClaw.Code.Commands/Options/GlobalCliOptions.cs +++ b/src/SharpClaw.Code.Commands/Options/GlobalCliOptions.cs @@ -63,7 +63,7 @@ public GlobalCliOptions() PrimaryModeOption = new Option("--primary-mode") { - Description = "Sets the primary workflow mode: build, plan, or spec.", + Description = "Sets the primary workflow mode: build, plan, spec, or research.", DefaultValueFactory = _ => "build", Recursive = true }; @@ -280,6 +280,7 @@ private static PrimaryMode ParsePrimaryMode(string value) { "plan" => PrimaryMode.Plan, "spec" => PrimaryMode.Spec, + "research" => PrimaryMode.Research, _ => PrimaryMode.Build, }; diff --git a/src/SharpClaw.Code.Commands/Repl/ReplHost.cs b/src/SharpClaw.Code.Commands/Repl/ReplHost.cs index a7a9b29..6af6d02 100644 --- a/src/SharpClaw.Code.Commands/Repl/ReplHost.cs +++ b/src/SharpClaw.Code.Commands/Repl/ReplHost.cs @@ -124,6 +124,7 @@ await outputRendererDispatcher.RenderCommandResultAsync( argLine, context.ToRuntimeCommandContext( primaryModeOverride: replInteractionState.PrimaryModeOverride ?? context.PrimaryMode, + permissionModeOverride: replInteractionState.PermissionModeOverride ?? context.PermissionMode, agentIdOverride: replInteractionState.AgentIdOverride ?? context.AgentId, approvalSettingsOverride: replInteractionState.ApprovalSettingsOverride), cancellationToken) @@ -190,6 +191,7 @@ private async Task ExecutePromptAsync(string prompt, CommandExecutionContex prompt, context.ToRuntimeCommandContext( primaryModeOverride: replInteractionState.PrimaryModeOverride ?? context.PrimaryMode, + permissionModeOverride: replInteractionState.PermissionModeOverride ?? context.PermissionMode, agentIdOverride: replInteractionState.AgentIdOverride ?? context.AgentId, approvalSettingsOverride: replInteractionState.ApprovalSettingsOverride), cancellationToken).ConfigureAwait(false); diff --git a/src/SharpClaw.Code.Commands/Repl/ReplInteractionState.cs b/src/SharpClaw.Code.Commands/Repl/ReplInteractionState.cs index 0cde805..55c78a1 100644 --- a/src/SharpClaw.Code.Commands/Repl/ReplInteractionState.cs +++ b/src/SharpClaw.Code.Commands/Repl/ReplInteractionState.cs @@ -13,6 +13,11 @@ public sealed class ReplInteractionState /// public PrimaryMode? PrimaryModeOverride { get; set; } + /// + /// When set, wins over for REPL turns. + /// + public PermissionMode? PermissionModeOverride { get; set; } + /// /// When set, wins over for REPL turns. /// @@ -22,4 +27,15 @@ public sealed class ReplInteractionState /// When set, wins over for REPL turns. /// public ApprovalSettings? ApprovalSettingsOverride { get; set; } + + /// + /// Clears ephemeral REPL overrides. + /// + public void ClearTransientOverrides() + { + PrimaryModeOverride = null; + PermissionModeOverride = null; + AgentIdOverride = null; + ApprovalSettingsOverride = null; + } } diff --git a/src/SharpClaw.Code.Infrastructure/Abstractions/IRuntimeStoragePathResolver.cs b/src/SharpClaw.Code.Infrastructure/Abstractions/IRuntimeStoragePathResolver.cs index 475b12d..3c2f9c2 100644 --- a/src/SharpClaw.Code.Infrastructure/Abstractions/IRuntimeStoragePathResolver.cs +++ b/src/SharpClaw.Code.Infrastructure/Abstractions/IRuntimeStoragePathResolver.cs @@ -59,6 +59,18 @@ public interface IRuntimeStoragePathResolver /// Gets the workspace telemetry directory path. string GetTelemetryRoot(string workspacePath); + /// Gets the workspace scheduled prompt catalog path. + string GetScheduledPromptsPath(string workspacePath); + + /// Gets the workspace scheduled prompt lock path. + string GetScheduledPromptsLockPath(string workspacePath); + + /// Gets the workspace evolution proposal catalog path. + string GetEvolutionProposalsPath(string workspacePath); + + /// Gets the workspace evolution proposal lock path. + string GetEvolutionProposalsLockPath(string workspacePath); + /// Gets the SQLite database path used by usage metering. string GetUsageMeteringDatabasePath(string workspacePath); diff --git a/src/SharpClaw.Code.Infrastructure/Abstractions/ISecretProtector.cs b/src/SharpClaw.Code.Infrastructure/Abstractions/ISecretProtector.cs new file mode 100644 index 0000000..c44ce96 --- /dev/null +++ b/src/SharpClaw.Code.Infrastructure/Abstractions/ISecretProtector.cs @@ -0,0 +1,22 @@ +namespace SharpClaw.Code.Infrastructure.Abstractions; + +/// +/// Protects and restores user-scoped secrets for local machine storage. +/// +public interface ISecretProtector +{ + /// + /// Gets whether the current platform can persist protected secrets locally. + /// + bool CanProtect { get; } + + /// + /// Protects a plaintext secret for the current user. + /// + string Protect(string plaintext); + + /// + /// Restores a previously protected secret for the current user. + /// + string Unprotect(string protectedPayload); +} diff --git a/src/SharpClaw.Code.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/src/SharpClaw.Code.Infrastructure/InfrastructureServiceCollectionExtensions.cs index 32864ed..28c2799 100644 --- a/src/SharpClaw.Code.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/src/SharpClaw.Code.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -23,6 +23,7 @@ public static IServiceCollection AddSharpClawInfrastructure(this IServiceCollect services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/SharpClaw.Code.Infrastructure/Services/PlatformSecretProtector.cs b/src/SharpClaw.Code.Infrastructure/Services/PlatformSecretProtector.cs new file mode 100644 index 0000000..baa9f0f --- /dev/null +++ b/src/SharpClaw.Code.Infrastructure/Services/PlatformSecretProtector.cs @@ -0,0 +1,38 @@ +using System.Security.Cryptography; +using System.Text; +using SharpClaw.Code.Infrastructure.Abstractions; + +namespace SharpClaw.Code.Infrastructure.Services; + +/// +public sealed class PlatformSecretProtector : ISecretProtector +{ + /// + public bool CanProtect => OperatingSystem.IsWindows(); + + /// + public string Protect(string plaintext) + { + ArgumentException.ThrowIfNullOrWhiteSpace(plaintext); + if (!CanProtect) + { + throw new InvalidOperationException("Protected local secret storage is only available on Windows."); + } + + var bytes = Encoding.UTF8.GetBytes(plaintext); + return Convert.ToBase64String(ProtectedData.Protect(bytes, optionalEntropy: null, DataProtectionScope.CurrentUser)); + } + + /// + public string Unprotect(string protectedPayload) + { + ArgumentException.ThrowIfNullOrWhiteSpace(protectedPayload); + if (!CanProtect) + { + throw new InvalidOperationException("Protected local secret storage is only available on Windows."); + } + + var bytes = Convert.FromBase64String(protectedPayload); + return Encoding.UTF8.GetString(ProtectedData.Unprotect(bytes, optionalEntropy: null, DataProtectionScope.CurrentUser)); + } +} diff --git a/src/SharpClaw.Code.Infrastructure/Services/RuntimeStoragePathResolver.cs b/src/SharpClaw.Code.Infrastructure/Services/RuntimeStoragePathResolver.cs index dc42d70..98a0510 100644 --- a/src/SharpClaw.Code.Infrastructure/Services/RuntimeStoragePathResolver.cs +++ b/src/SharpClaw.Code.Infrastructure/Services/RuntimeStoragePathResolver.cs @@ -97,6 +97,22 @@ public string GetExportsRoot(string workspacePath) public string GetTelemetryRoot(string workspacePath) => pathService.Combine(GetSharpClawRoot(workspacePath), "telemetry"); + /// + public string GetScheduledPromptsPath(string workspacePath) + => pathService.Combine(GetSharpClawRoot(workspacePath), "scheduled-prompts.json"); + + /// + public string GetScheduledPromptsLockPath(string workspacePath) + => pathService.Combine(GetSharpClawRoot(workspacePath), ".scheduled-prompts.lock"); + + /// + public string GetEvolutionProposalsPath(string workspacePath) + => pathService.Combine(GetSharpClawRoot(workspacePath), "evolution-proposals.json"); + + /// + public string GetEvolutionProposalsLockPath(string workspacePath) + => pathService.Combine(GetSharpClawRoot(workspacePath), ".evolution-proposals.lock"); + /// public string GetUsageMeteringDatabasePath(string workspacePath) => pathService.Combine(GetTelemetryRoot(workspacePath), "usage-metering.db"); diff --git a/src/SharpClaw.Code.Permissions/Rules/PrimaryModeMutationRule.cs b/src/SharpClaw.Code.Permissions/Rules/PrimaryModeMutationRule.cs index 15d1efd..044af93 100644 --- a/src/SharpClaw.Code.Permissions/Rules/PrimaryModeMutationRule.cs +++ b/src/SharpClaw.Code.Permissions/Rules/PrimaryModeMutationRule.cs @@ -6,7 +6,7 @@ namespace SharpClaw.Code.Permissions.Rules; /// -/// Blocks mutating tool executions while the session is in . +/// Blocks mutating tool executions while the session is in a read-only workflow mode. /// public sealed class PrimaryModeMutationRule : IPermissionRule { @@ -16,19 +16,21 @@ public Task EvaluateAsync( PermissionEvaluationContext context, CancellationToken cancellationToken) { - if (context.PrimaryMode != PrimaryMode.Plan) + if (context.PrimaryMode is not (PrimaryMode.Plan or PrimaryMode.Research)) { return Task.FromResult(PermissionRuleResult.Abstain()); } + var modeLabel = context.PrimaryMode == PrimaryMode.Research ? "Research mode" : "Plan mode"; + if (request.IsDestructive) { - return Task.FromResult(PermissionRuleResult.Deny("Plan mode blocks mutating tools.")); + return Task.FromResult(PermissionRuleResult.Deny($"{modeLabel} blocks mutating tools.")); } if (request.ApprovalScope is ApprovalScope.FileSystemWrite or ApprovalScope.ShellExecution) { - return Task.FromResult(PermissionRuleResult.Deny($"Plan mode blocks {request.ApprovalScope}.")); + return Task.FromResult(PermissionRuleResult.Deny($"{modeLabel} blocks {request.ApprovalScope}.")); } return Task.FromResult(PermissionRuleResult.Abstain()); diff --git a/src/SharpClaw.Code.Protocol/Commands/RunPromptRequest.cs b/src/SharpClaw.Code.Protocol/Commands/RunPromptRequest.cs index 9196ce4..e983a99 100644 --- a/src/SharpClaw.Code.Protocol/Commands/RunPromptRequest.cs +++ b/src/SharpClaw.Code.Protocol/Commands/RunPromptRequest.cs @@ -18,6 +18,7 @@ namespace SharpClaw.Code.Protocol.Commands; /// Whether the caller can participate in approval prompts. /// Optional embedded host and tenant context. /// Optional per-session auto-approval settings. +/// Optional structured user content blocks that supplement or replace plain-text prompt content. public sealed record RunPromptRequest( string Prompt, string? SessionId, @@ -30,4 +31,5 @@ public sealed record RunPromptRequest( DelegatedTaskContract? DelegatedTask = null, bool IsInteractive = true, RuntimeHostContext? HostContext = null, - ApprovalSettings? ApprovalSettings = null); + ApprovalSettings? ApprovalSettings = null, + IReadOnlyList? UserContent = null); diff --git a/src/SharpClaw.Code.Protocol/Enums/PrimaryMode.cs b/src/SharpClaw.Code.Protocol/Enums/PrimaryMode.cs index c1e0762..6d32330 100644 --- a/src/SharpClaw.Code.Protocol/Enums/PrimaryMode.cs +++ b/src/SharpClaw.Code.Protocol/Enums/PrimaryMode.cs @@ -25,4 +25,10 @@ public enum PrimaryMode /// [JsonStringEnumMemberName("spec")] Spec, + + /// + /// Research posture: read-only investigation with explicit sourcing and confidence notes. + /// + [JsonStringEnumMemberName("research")] + Research, } diff --git a/src/SharpClaw.Code.Protocol/Models/AdaLGapModels.cs b/src/SharpClaw.Code.Protocol/Models/AdaLGapModels.cs new file mode 100644 index 0000000..6c3406d --- /dev/null +++ b/src/SharpClaw.Code.Protocol/Models/AdaLGapModels.cs @@ -0,0 +1,198 @@ +using System.Text.Json.Serialization; +using SharpClaw.Code.Protocol.Enums; + +namespace SharpClaw.Code.Protocol.Models; + +/// +/// Declares a durable trusted-source category. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum TrustedSourceKind +{ + /// Plugin id. + [JsonStringEnumMemberName("plugin")] + Plugin, + + /// MCP server name. + [JsonStringEnumMemberName("mcp")] + Mcp, +} + +/// +/// One trusted plugin or MCP server persisted for a session. +/// +public sealed record TrustedSourceEntry( + TrustedSourceKind Kind, + string Name, + DateTimeOffset GrantedAtUtc); + +/// +/// Summarizes the effective permission posture for the active workspace/session. +/// +public sealed record PermissionStatusReport( + PermissionMode PermissionMode, + ApprovalSettings? ApprovalSettings, + TrustedSourceEntry[] TrustedSources, + string? AttachedSessionId, + string? EffectiveModel); + +/// +/// Persists a preferred model selection for a durable session. +/// +public sealed record SessionModelPreference( + string? Model, + DateTimeOffset UpdatedAtUtc); + +/// +/// Declares how a scheduled prompt chooses its target session. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ScheduledPromptSessionTargetKind +{ + /// Create a fresh session for each run. + [JsonStringEnumMemberName("new")] + New, + + /// Reuse the current attached session for the workspace. + [JsonStringEnumMemberName("attached")] + Attached, + + /// Reuse one explicit session id. + [JsonStringEnumMemberName("explicit")] + Explicit, +} + +/// +/// Identifies the session target used by a scheduled prompt. +/// +public sealed record ScheduledPromptSessionTarget( + ScheduledPromptSessionTargetKind Kind, + string? SessionId = null); + +/// +/// Summarizes the last known outcome for one scheduled prompt run. +/// +public sealed record ScheduledPromptLastOutcome( + bool Succeeded, + string Message, + DateTimeOffset OccurredAtUtc, + string? SessionId = null); + +/// +/// Durable workspace-local scheduled prompt definition. +/// +public sealed record ScheduledPromptDefinition( + string Id, + string WorkspaceRoot, + string Name, + string Prompt, + string Cron, + PrimaryMode PrimaryMode, + string? ModelOverride, + PermissionMode PermissionMode, + ApprovalSettings? ApprovalSettings, + ScheduledPromptSessionTarget SessionTarget, + bool Enabled, + DateTimeOffset? LastRunUtc, + DateTimeOffset? NextRunUtc, + ScheduledPromptLastOutcome? LastOutcome); + +/// +/// Summarizes one schedule execution attempt. +/// +public sealed record ScheduledPromptRunReport( + string ScheduleId, + string Name, + bool Succeeded, + string Message, + DateTimeOffset StartedAtUtc, + DateTimeOffset CompletedAtUtc, + string? SessionId = null); + +/// +/// Request contract for citation-oriented research mode. +/// +public sealed record ResearchRequest( + string Prompt, + int MaxSources = 8, + bool UseSubAgents = true); + +/// +/// One cited research source. +/// +public sealed record ResearchSource( + string Title, + string Url, + string? Snippet, + string SourceKind, + string? ConfidenceNote = null); + +/// +/// Structured research report shape used by commands and tests. +/// +public sealed record ResearchReport( + string Summary, + string[] Findings, + ResearchSource[] Sources, + string[] ConfidenceNotes, + string[] UnresolvedQuestions); + +/// +/// Declares a guided self-evolution proposal category. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum EvolutionProposalCategory +{ + [JsonStringEnumMemberName("promptPolicy")] + PromptPolicy, + + [JsonStringEnumMemberName("modelRouting")] + ModelRouting, + + [JsonStringEnumMemberName("approvalDefaults")] + ApprovalDefaults, + + [JsonStringEnumMemberName("skillSuggestion")] + SkillSuggestion, + + [JsonStringEnumMemberName("pluginSuggestion")] + PluginSuggestion, + + [JsonStringEnumMemberName("knowledgeRefresh")] + KnowledgeRefresh, + + [JsonStringEnumMemberName("codeSpec")] + CodeSpec, +} + +/// +/// Lifecycle status for a durable evolution proposal. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum EvolutionProposalStatus +{ + [JsonStringEnumMemberName("open")] + Open, + + [JsonStringEnumMemberName("applied")] + Applied, + + [JsonStringEnumMemberName("rejected")] + Rejected, +} + +/// +/// Durable guided self-evolution proposal. +/// +public sealed record EvolutionProposal( + string Id, + string WorkspaceRoot, + EvolutionProposalCategory Category, + EvolutionProposalStatus Status, + string Title, + string Summary, + string[] Evidence, + string[] RecommendedActions, + DateTimeOffset CreatedAtUtc, + DateTimeOffset? UpdatedAtUtc = null, + string? AppliedBy = null); diff --git a/src/SharpClaw.Code.Protocol/Models/AuthStatus.cs b/src/SharpClaw.Code.Protocol/Models/AuthStatus.cs index 4f415b8..b0bfd23 100644 --- a/src/SharpClaw.Code.Protocol/Models/AuthStatus.cs +++ b/src/SharpClaw.Code.Protocol/Models/AuthStatus.cs @@ -9,10 +9,16 @@ namespace SharpClaw.Code.Protocol.Models; /// The related organization or tenant identifier, if any. /// The UTC expiration timestamp, if known. /// The granted scopes or permissions associated with the status. +/// Where the active auth material came from. +/// Optional auth detail suitable for CLI/status output. +/// Whether the status describes a local runtime profile that may not require credentials. public sealed record AuthStatus( string? SubjectId, bool IsAuthenticated, string? ProviderName, string? OrganizationId, DateTimeOffset? ExpiresAtUtc, - string[]? GrantedScopes); + string[]? GrantedScopes, + string? SourceType = null, + string? StatusDetail = null, + bool IsLocalRuntime = false); diff --git a/src/SharpClaw.Code.Protocol/Models/ContentBlock.cs b/src/SharpClaw.Code.Protocol/Models/ContentBlock.cs index 43c9d3b..3fc3b96 100644 --- a/src/SharpClaw.Code.Protocol/Models/ContentBlock.cs +++ b/src/SharpClaw.Code.Protocol/Models/ContentBlock.cs @@ -25,6 +25,12 @@ public enum ContentBlockKind /// [JsonStringEnumMemberName("tool_result")] ToolResult, + + /// + /// Binary or URI-backed image input. + /// + [JsonStringEnumMemberName("image")] + Image, } /// @@ -36,10 +42,16 @@ public enum ContentBlockKind /// Tool name, used for kind. /// Tool input serialized as a JSON string, used for kind. /// Whether the tool result represents an error, used for kind. +/// Optional media type for binary data, used for . +/// Optional raw data payload, typically base64 for image input. +/// Optional source URI for externally addressable content. public sealed record ContentBlock( ContentBlockKind Kind, string? Text, string? ToolUseId, string? ToolName, string? ToolInputJson, - bool? IsError); + bool? IsError, + string? MediaType = null, + string? Data = null, + string? Uri = null); diff --git a/src/SharpClaw.Code.Protocol/Models/OpenCodeParityModels.cs b/src/SharpClaw.Code.Protocol/Models/OpenCodeParityModels.cs index 2f38a91..490dca0 100644 --- a/src/SharpClaw.Code.Protocol/Models/OpenCodeParityModels.cs +++ b/src/SharpClaw.Code.Protocol/Models/OpenCodeParityModels.cs @@ -235,6 +235,7 @@ public sealed record AgentCatalogEntry( /// Current authentication state. /// Whether tool calling is supported. /// Whether embeddings are supported. +/// Whether multimodal image input is supported. /// Discovered models for the provider. /// Configured local runtime profiles, if any. public sealed record ProviderModelCatalogEntry( @@ -244,6 +245,7 @@ public sealed record ProviderModelCatalogEntry( AuthStatus AuthStatus, bool SupportsToolCalls = true, bool SupportsEmbeddings = false, + bool SupportsImageInput = false, ProviderDiscoveredModel[]? AvailableModels = null, LocalRuntimeProfileSummary[]? LocalRuntimeProfiles = null); diff --git a/src/SharpClaw.Code.Protocol/Models/PromptReferences.cs b/src/SharpClaw.Code.Protocol/Models/PromptReferences.cs index 75040ae..8be3a0a 100644 --- a/src/SharpClaw.Code.Protocol/Models/PromptReferences.cs +++ b/src/SharpClaw.Code.Protocol/Models/PromptReferences.cs @@ -7,6 +7,12 @@ public enum PromptReferenceKind { /// File path reference. File, + + /// Directory path reference. + Directory, + + /// Image file reference. + Image, } /// @@ -19,7 +25,9 @@ public sealed record PromptReference( string ResolvedFullPath, string DisplayPath, bool WasOutsideWorkspace, - string IncludedContent); + string IncludedContent, + string? MediaType = null, + int? IncludedEntryCount = null); /// /// Result of expanding all @file tokens in a prompt. diff --git a/src/SharpClaw.Code.Protocol/Models/ProviderRequest.cs b/src/SharpClaw.Code.Protocol/Models/ProviderRequest.cs index e9ab5f2..cd0feef 100644 --- a/src/SharpClaw.Code.Protocol/Models/ProviderRequest.cs +++ b/src/SharpClaw.Code.Protocol/Models/ProviderRequest.cs @@ -18,6 +18,7 @@ namespace SharpClaw.Code.Protocol.Models; /// The conversation history to send to the provider, if any. /// The tool definitions available to the provider, if any. /// The maximum number of tokens to generate, if any. +/// Whether the request contains structured image content blocks. public sealed record ProviderRequest( string Id, string SessionId, @@ -31,4 +32,5 @@ public sealed record ProviderRequest( Dictionary? Metadata, IReadOnlyList? Messages = null, IReadOnlyList? Tools = null, - int? MaxTokens = null); + int? MaxTokens = null, + bool ContainsImageInput = false); diff --git a/src/SharpClaw.Code.Protocol/Models/SharpClawWorkflowMetadataKeys.cs b/src/SharpClaw.Code.Protocol/Models/SharpClawWorkflowMetadataKeys.cs index 1683075..97b6f86 100644 --- a/src/SharpClaw.Code.Protocol/Models/SharpClawWorkflowMetadataKeys.cs +++ b/src/SharpClaw.Code.Protocol/Models/SharpClawWorkflowMetadataKeys.cs @@ -74,6 +74,18 @@ public static class SharpClawWorkflowMetadataKeys /// Most recent deep-planning next action captured for the session. public const string DeepPlanningNextAction = "sharpclaw.deepPlanningNextAction"; + /// Preferred permission mode persisted for the session. + public const string PreferredPermissionMode = "sharpclaw.preferredPermissionMode"; + + /// JSON array of trusted plugin ids for the session. + public const string TrustedPluginNamesJson = "sharpclaw.trustedPluginNamesJson"; + + /// JSON array of trusted MCP server names for the session. + public const string TrustedMcpServerNamesJson = "sharpclaw.trustedMcpServerNamesJson"; + + /// Serialized persisted for the session. + public const string SessionModelPreferenceJson = "sharpclaw.sessionModelPreferenceJson"; + /// Prefix for managed todo id maps keyed by owner agent id. public const string ManagedSessionTodoMapPrefix = "sharpclaw.managedSessionTodoMap."; diff --git a/src/SharpClaw.Code.Protocol/Serialization/ProtocolJsonContext.cs b/src/SharpClaw.Code.Protocol/Serialization/ProtocolJsonContext.cs index 24dc9cd..4f1570c 100644 --- a/src/SharpClaw.Code.Protocol/Serialization/ProtocolJsonContext.cs +++ b/src/SharpClaw.Code.Protocol/Serialization/ProtocolJsonContext.cs @@ -134,6 +134,26 @@ namespace SharpClaw.Code.Protocol.Serialization; [JsonSerializable(typeof(PromptReferenceResolution))] [JsonSerializable(typeof(PromptOutsideWorkspaceReadArguments))] [JsonSerializable(typeof(List))] +[JsonSerializable(typeof(TrustedSourceKind))] +[JsonSerializable(typeof(TrustedSourceEntry))] +[JsonSerializable(typeof(TrustedSourceEntry[]))] +[JsonSerializable(typeof(PermissionStatusReport))] +[JsonSerializable(typeof(SessionModelPreference))] +[JsonSerializable(typeof(ScheduledPromptSessionTargetKind))] +[JsonSerializable(typeof(ScheduledPromptSessionTarget))] +[JsonSerializable(typeof(ScheduledPromptLastOutcome))] +[JsonSerializable(typeof(ScheduledPromptDefinition))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(ScheduledPromptRunReport))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(ResearchRequest))] +[JsonSerializable(typeof(ResearchSource))] +[JsonSerializable(typeof(ResearchSource[]))] +[JsonSerializable(typeof(ResearchReport))] +[JsonSerializable(typeof(EvolutionProposalCategory))] +[JsonSerializable(typeof(EvolutionProposalStatus))] +[JsonSerializable(typeof(EvolutionProposal))] +[JsonSerializable(typeof(List))] [JsonSerializable(typeof(SessionExportFormat))] [JsonSerializable(typeof(SessionExportToolAction))] [JsonSerializable(typeof(List))] diff --git a/src/SharpClaw.Code.Providers/Abstractions/IModelProvider.cs b/src/SharpClaw.Code.Providers/Abstractions/IModelProvider.cs index 4e42856..b995ab7 100644 --- a/src/SharpClaw.Code.Providers/Abstractions/IModelProvider.cs +++ b/src/SharpClaw.Code.Providers/Abstractions/IModelProvider.cs @@ -13,6 +13,11 @@ public interface IModelProvider /// string ProviderName { get; } + /// + /// Gets whether the provider accepts structured image input. + /// + bool SupportsImageInput { get; } + /// /// Gets the current authentication status for the provider. /// diff --git a/src/SharpClaw.Code.Providers/Abstractions/IProviderCredentialStore.cs b/src/SharpClaw.Code.Providers/Abstractions/IProviderCredentialStore.cs new file mode 100644 index 0000000..b91231b --- /dev/null +++ b/src/SharpClaw.Code.Providers/Abstractions/IProviderCredentialStore.cs @@ -0,0 +1,32 @@ +namespace SharpClaw.Code.Providers.Abstractions; + +/// +/// Resolves and persists user-scoped provider credentials without writing plaintext workspace state. +/// +public interface IProviderCredentialStore +{ + /// + /// Resolves the effective API key for a provider, if available. + /// + Task ResolveAsync(string providerName, CancellationToken cancellationToken); + + /// + /// Lists stored credential descriptors without exposing secret material. + /// + Task> ListAsync(CancellationToken cancellationToken); + + /// + /// Stores an environment-variable reference for the provider. + /// + Task SetEnvironmentVariableAsync(string providerName, string environmentVariableName, CancellationToken cancellationToken); + + /// + /// Stores a protected secret for the provider when supported on the current platform. + /// + Task SetProtectedSecretAsync(string providerName, string apiKey, CancellationToken cancellationToken); + + /// + /// Clears any stored credential reference for the provider. + /// + Task ClearAsync(string providerName, CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.Providers/AnthropicProvider.cs b/src/SharpClaw.Code.Providers/AnthropicProvider.cs index c370587..f9428f6 100644 --- a/src/SharpClaw.Code.Providers/AnthropicProvider.cs +++ b/src/SharpClaw.Code.Providers/AnthropicProvider.cs @@ -17,6 +17,7 @@ namespace SharpClaw.Code.Providers; /// public sealed class AnthropicProvider( IOptions options, + IProviderCredentialStore credentialStore, ISystemClock systemClock, ILogger logger) : IModelProvider { @@ -26,17 +27,26 @@ public sealed class AnthropicProvider( public string ProviderName => _options.ProviderName; /// - public Task GetAuthStatusAsync(CancellationToken cancellationToken) - => Task.FromResult(Internal.ProviderAuthStatusFactory.FromConfiguration( + public bool SupportsImageInput => _options.SupportsImageInput; + + /// + public async Task GetAuthStatusAsync(CancellationToken cancellationToken) + { + var resolved = await ResolveCredentialAsync(cancellationToken).ConfigureAwait(false); + return Internal.ProviderAuthStatusFactory.FromConfiguration( ProviderName, - _options.ApiKey, + resolved.ApiKey, ProviderAuthMode.ApiKey, - hasAuthOptionalRuntime: false)); + hasAuthOptionalRuntime: false, + sourceType: resolved.SourceType ?? (string.IsNullOrWhiteSpace(_options.ApiKey) ? null : "config"), + statusDetail: resolved.StatusDetail ?? (string.IsNullOrWhiteSpace(_options.ApiKey) ? null : "configured API key")); + } /// public async Task StartStreamAsync(ProviderRequest request, CancellationToken cancellationToken) { - var client = CreateClient(); + var resolved = await ResolveCredentialAsync(cancellationToken).ConfigureAwait(false); + var client = CreateClient(resolved.ApiKey); var modelId = Internal.ProviderHttpHelpers.ResolveModelOrDefault(request.Model, _options.DefaultModel); var systemPrompt = string.IsNullOrWhiteSpace(request.SystemPrompt) ? null : request.SystemPrompt; @@ -90,9 +100,9 @@ public async Task StartStreamAsync(ProviderRequest request return new ProviderStreamHandle(request, AnthropicSdkStreamAdapter.AdaptAsync(stream, request.Id, systemClock, cancellationToken)); } - private AnthropicClient CreateClient() + private AnthropicClient CreateClient(string? resolvedApiKey) { - var apiKey = _options.ApiKey ?? string.Empty; + var apiKey = resolvedApiKey ?? _options.ApiKey ?? string.Empty; var clientOptions = new ClientOptions { ApiKey = apiKey, @@ -106,4 +116,14 @@ private AnthropicClient CreateClient() return new AnthropicClient(clientOptions); } + + private async Task ResolveCredentialAsync(CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(_options.ApiKey)) + { + return new ResolvedProviderCredential(_options.ApiKey, "config", "configured API key"); + } + + return await credentialStore.ResolveAsync(ProviderName, cancellationToken).ConfigureAwait(false); + } } diff --git a/src/SharpClaw.Code.Providers/Configuration/AnthropicProviderOptions.cs b/src/SharpClaw.Code.Providers/Configuration/AnthropicProviderOptions.cs index 8f778cd..2f8490c 100644 --- a/src/SharpClaw.Code.Providers/Configuration/AnthropicProviderOptions.cs +++ b/src/SharpClaw.Code.Providers/Configuration/AnthropicProviderOptions.cs @@ -29,4 +29,9 @@ public sealed class AnthropicProviderOptions /// Gets or sets the default model id. /// public string DefaultModel { get; set; } = "claude-3-7-sonnet-latest"; + + /// + /// Gets or sets whether the provider supports structured image input. + /// + public bool SupportsImageInput { get; set; } = true; } diff --git a/src/SharpClaw.Code.Providers/Configuration/OpenAiCompatibleProviderOptions.cs b/src/SharpClaw.Code.Providers/Configuration/OpenAiCompatibleProviderOptions.cs index f5e6368..d2d0988 100644 --- a/src/SharpClaw.Code.Providers/Configuration/OpenAiCompatibleProviderOptions.cs +++ b/src/SharpClaw.Code.Providers/Configuration/OpenAiCompatibleProviderOptions.cs @@ -47,6 +47,11 @@ public sealed class OpenAiCompatibleProviderOptions /// public bool SupportsEmbeddings { get; set; } + /// + /// Gets or sets whether the endpoint supports structured image input. + /// + public bool SupportsImageInput { get; set; } = true; + /// /// Gets the configured named local runtime profiles. /// diff --git a/src/SharpClaw.Code.Providers/Internal/AnthropicMessageBuilder.cs b/src/SharpClaw.Code.Providers/Internal/AnthropicMessageBuilder.cs index e367bc3..1454fc8 100644 --- a/src/SharpClaw.Code.Providers/Internal/AnthropicMessageBuilder.cs +++ b/src/SharpClaw.Code.Providers/Internal/AnthropicMessageBuilder.cs @@ -68,6 +68,20 @@ private static MessageParam BuildMessageParam(ChatMessage message) var textParam = new TextBlockParam { Text = block.Text ?? string.Empty }; return new ContentBlockParam(textParam, null); + case ContentBlockKind.Image: + if (string.IsNullOrWhiteSpace(block.Data)) + { + return null; + } + + var imageSource = new Base64ImageSource + { + Data = block.Data, + MediaType = ResolveMediaType(block.MediaType), + }; + var imageParam = new ImageBlockParam(new ImageBlockParamSource(imageSource, null)); + return new ContentBlockParam(imageParam, null); + case ContentBlockKind.ToolUse: var input = ParseInputJson(block.ToolInputJson); var toolUseParam = new ToolUseBlockParam @@ -145,4 +159,13 @@ private static IReadOnlyDictionary ParseSchemaToRawData(str p => p.Value.Clone()); } } + + private static MediaType ResolveMediaType(string? mediaType) + => mediaType?.Trim().ToLowerInvariant() switch + { + "image/png" => MediaType.ImagePng, + "image/gif" => MediaType.ImageGif, + "image/webp" => MediaType.ImageWebP, + _ => MediaType.ImageJpeg, + }; } diff --git a/src/SharpClaw.Code.Providers/Internal/OpenAiMessageBuilder.cs b/src/SharpClaw.Code.Providers/Internal/OpenAiMessageBuilder.cs index d48ce7b..5fce2aa 100644 --- a/src/SharpClaw.Code.Providers/Internal/OpenAiMessageBuilder.cs +++ b/src/SharpClaw.Code.Providers/Internal/OpenAiMessageBuilder.cs @@ -94,6 +94,12 @@ public static List BuildTools(IReadOnlyList tool { ContentBlockKind.Text => new TextContent(block.Text ?? string.Empty), + ContentBlockKind.Image => string.IsNullOrWhiteSpace(block.Data) + ? null + : new DataContent( + Convert.FromBase64String(block.Data), + block.MediaType ?? "application/octet-stream"), + ContentBlockKind.ToolUse => new FunctionCallContent( callId: block.ToolUseId ?? string.Empty, name: block.ToolName ?? string.Empty, diff --git a/src/SharpClaw.Code.Providers/Internal/ProviderAuthStatusFactory.cs b/src/SharpClaw.Code.Providers/Internal/ProviderAuthStatusFactory.cs index 47350f1..ddf848c 100644 --- a/src/SharpClaw.Code.Providers/Internal/ProviderAuthStatusFactory.cs +++ b/src/SharpClaw.Code.Providers/Internal/ProviderAuthStatusFactory.cs @@ -8,7 +8,10 @@ public static AuthStatus FromConfiguration( string providerName, string? apiKey, ProviderAuthMode authMode, - bool hasAuthOptionalRuntime) + bool hasAuthOptionalRuntime, + string? sourceType = null, + string? statusDetail = null, + bool isLocalRuntime = false) { ArgumentException.ThrowIfNullOrWhiteSpace(providerName); var ok = authMode switch @@ -23,6 +26,9 @@ public static AuthStatus FromConfiguration( ProviderName: providerName, OrganizationId: null, ExpiresAtUtc: null, - GrantedScopes: ok ? ["api"] : []); + GrantedScopes: ok ? ["api"] : [], + SourceType: sourceType, + StatusDetail: statusDetail, + IsLocalRuntime: isLocalRuntime); } } diff --git a/src/SharpClaw.Code.Providers/Models/ProviderCredentialModels.cs b/src/SharpClaw.Code.Providers/Models/ProviderCredentialModels.cs new file mode 100644 index 0000000..64911b1 --- /dev/null +++ b/src/SharpClaw.Code.Providers/Models/ProviderCredentialModels.cs @@ -0,0 +1,18 @@ +namespace SharpClaw.Code.Providers; + +/// +/// Stored user-scoped provider credential descriptor. +/// +public sealed record ProviderCredentialDescriptor( + string ProviderName, + string SourceType, + string? EnvironmentVariableName, + DateTimeOffset UpdatedAtUtc); + +/// +/// Resolved provider credential payload for runtime use. +/// +public sealed record ResolvedProviderCredential( + string? ApiKey, + string? SourceType, + string? StatusDetail); diff --git a/src/SharpClaw.Code.Providers/OpenAiCompatibleProvider.cs b/src/SharpClaw.Code.Providers/OpenAiCompatibleProvider.cs index a675a16..deed705 100644 --- a/src/SharpClaw.Code.Providers/OpenAiCompatibleProvider.cs +++ b/src/SharpClaw.Code.Providers/OpenAiCompatibleProvider.cs @@ -18,40 +18,50 @@ namespace SharpClaw.Code.Providers; /// public sealed class OpenAiCompatibleProvider( IOptions options, + IProviderCredentialStore credentialStore, ISystemClock systemClock, ILogger logger) : IModelProvider { private readonly OpenAiCompatibleProviderOptions _options = options.Value; - private OpenAIClient? _cachedOpenAiClient; internal const string RuntimeProfileMetadataKey = "openai-compatible.profile"; /// public string ProviderName => _options.ProviderName; /// - public Task GetAuthStatusAsync(CancellationToken cancellationToken) - => Task.FromResult(Internal.ProviderAuthStatusFactory.FromConfiguration( + public bool SupportsImageInput => _options.SupportsImageInput; + + /// + public async Task GetAuthStatusAsync(CancellationToken cancellationToken) + { + var resolved = await ResolveCredentialAsync(cancellationToken).ConfigureAwait(false); + return Internal.ProviderAuthStatusFactory.FromConfiguration( ProviderName, - _options.ApiKey, + resolved.ApiKey, _options.AuthMode, - _options.LocalRuntimes.Values.Any(static runtime => runtime.AuthMode != ProviderAuthMode.ApiKey))); + _options.LocalRuntimes.Values.Any(static runtime => runtime.AuthMode != ProviderAuthMode.ApiKey), + sourceType: resolved.SourceType ?? (string.IsNullOrWhiteSpace(_options.ApiKey) ? null : "config"), + statusDetail: resolved.StatusDetail ?? (string.IsNullOrWhiteSpace(_options.ApiKey) ? null : "configured API key")); + } /// - public Task StartStreamAsync(ProviderRequest request, CancellationToken cancellationToken) + public async Task StartStreamAsync(ProviderRequest request, CancellationToken cancellationToken) { logger.LogInformation("Starting OpenAI-compatible MEAI stream for request {RequestId}.", request.Id); - return Task.FromResult(new ProviderStreamHandle(request, StreamEventsAsync(request, cancellationToken))); + var resolved = await ResolveCredentialAsync(cancellationToken).ConfigureAwait(false); + return new ProviderStreamHandle(request, StreamEventsAsync(request, resolved.ApiKey, cancellationToken)); } private async IAsyncEnumerable StreamEventsAsync( ProviderRequest request, + string? resolvedApiKey, [EnumeratorCancellation] CancellationToken cancellationToken) { var profile = ResolveProfile(request.Metadata); var modelId = Internal.ProviderHttpHelpers.ResolveModelOrDefault( request.Model, profile?.DefaultChatModel ?? _options.DefaultModel); - var openAiClient = GetOrCreateOpenAiClient(profile); + var openAiClient = CreateOpenAiClient(profile, resolvedApiKey); var nativeClient = openAiClient.GetChatClient(modelId); using var chatClient = nativeClient.AsIChatClient(); @@ -84,13 +94,8 @@ private async IAsyncEnumerable StreamEventsAsync( } } - private OpenAIClient GetOrCreateOpenAiClient(LocalRuntimeProfileOptions? profile) + private OpenAIClient CreateOpenAiClient(LocalRuntimeProfileOptions? profile, string? resolvedApiKey) { - if (profile is null && _cachedOpenAiClient is not null) - { - return _cachedOpenAiClient; - } - var openAiOptions = new OpenAIClientOptions(); var normalized = Internal.ProviderHttpHelpers.NormalizeBaseUrl(profile?.BaseUrl ?? _options.BaseUrl); if (normalized is not null) @@ -98,15 +103,9 @@ private OpenAIClient GetOrCreateOpenAiClient(LocalRuntimeProfileOptions? profile openAiOptions.Endpoint = new Uri(normalized); } - var apiKey = profile?.ApiKey ?? _options.ApiKey ?? "local-runtime"; + var apiKey = profile?.ApiKey ?? resolvedApiKey ?? _options.ApiKey ?? "local-runtime"; var credential = new ApiKeyCredential(apiKey); - var client = new OpenAIClient(credential, openAiOptions); - if (profile is null) - { - _cachedOpenAiClient = client; - } - - return client; + return new OpenAIClient(credential, openAiOptions); } private static List BuildChatMessages(ProviderRequest request) @@ -134,4 +133,14 @@ private OpenAIClient GetOrCreateOpenAiClient(LocalRuntimeProfileOptions? profile ? profile : null; } + + private async Task ResolveCredentialAsync(CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(_options.ApiKey)) + { + return new ResolvedProviderCredential(_options.ApiKey, "config", "configured API key"); + } + + return await credentialStore.ResolveAsync(ProviderName, cancellationToken).ConfigureAwait(false); + } } diff --git a/src/SharpClaw.Code.Providers/ProvidersServiceCollectionExtensions.cs b/src/SharpClaw.Code.Providers/ProvidersServiceCollectionExtensions.cs index ddb5776..a3af9fd 100644 --- a/src/SharpClaw.Code.Providers/ProvidersServiceCollectionExtensions.cs +++ b/src/SharpClaw.Code.Providers/ProvidersServiceCollectionExtensions.cs @@ -115,6 +115,7 @@ private static IServiceCollection AddSharpClawProvidersCore( services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(serviceProvider => WrapWithResilience(serviceProvider, serviceProvider.GetRequiredService())); diff --git a/src/SharpClaw.Code.Providers/Services/ProviderCatalogService.cs b/src/SharpClaw.Code.Providers/Services/ProviderCatalogService.cs index 721de60..5c23a79 100644 --- a/src/SharpClaw.Code.Providers/Services/ProviderCatalogService.cs +++ b/src/SharpClaw.Code.Providers/Services/ProviderCatalogService.cs @@ -36,6 +36,9 @@ public async Task> ListAsync(Cancellati : true; var supportsEmbeddings = string.Equals(provider.ProviderName, openAiOptions.Value.ProviderName, StringComparison.OrdinalIgnoreCase) && (openAiOptions.Value.SupportsEmbeddings || !string.IsNullOrWhiteSpace(openAiOptions.Value.DefaultEmbeddingModel)); + var supportsImageInput = string.Equals(provider.ProviderName, anthropicOptions.Value.ProviderName, StringComparison.OrdinalIgnoreCase) + ? anthropicOptions.Value.SupportsImageInput + : openAiOptions.Value.SupportsImageInput; var localProfiles = string.Equals(provider.ProviderName, openAiOptions.Value.ProviderName, StringComparison.OrdinalIgnoreCase) ? await BuildLocalRuntimeProfilesAsync(cancellationToken).ConfigureAwait(false) : []; @@ -52,6 +55,7 @@ public async Task> ListAsync(Cancellati AuthStatus: auth, SupportsToolCalls: supportsToolCalls, SupportsEmbeddings: supportsEmbeddings, + SupportsImageInput: supportsImageInput, AvailableModels: availableModels, LocalRuntimeProfiles: localProfiles)); } diff --git a/src/SharpClaw.Code.Providers/Services/ProviderCredentialStore.cs b/src/SharpClaw.Code.Providers/Services/ProviderCredentialStore.cs new file mode 100644 index 0000000..50db755 --- /dev/null +++ b/src/SharpClaw.Code.Providers/Services/ProviderCredentialStore.cs @@ -0,0 +1,137 @@ +using System.Text.Json; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Providers.Abstractions; + +namespace SharpClaw.Code.Providers.Services; + +/// +public sealed class ProviderCredentialStore( + IFileSystem fileSystem, + IUserProfilePaths userProfilePaths, + IPathService pathService, + ISecretProtector secretProtector) : IProviderCredentialStore +{ + private const string CredentialsFileName = "credentials.json"; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; + + /// + public async Task ResolveAsync(string providerName, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(providerName); + + var doc = await LoadAsync(cancellationToken).ConfigureAwait(false); + if (!doc.Providers.TryGetValue(providerName, out var entry)) + { + return new ResolvedProviderCredential(null, null, null); + } + + if (!string.IsNullOrWhiteSpace(entry.EnvironmentVariableName)) + { + var value = Environment.GetEnvironmentVariable(entry.EnvironmentVariableName); + return new ResolvedProviderCredential( + string.IsNullOrWhiteSpace(value) ? null : value, + "env", + $"environment variable {entry.EnvironmentVariableName}"); + } + + if (!string.IsNullOrWhiteSpace(entry.ProtectedSecret)) + { + return new ResolvedProviderCredential( + secretProtector.Unprotect(entry.ProtectedSecret), + "protectedStore", + "protected local user store"); + } + + return new ResolvedProviderCredential(null, entry.SourceType, null); + } + + /// + public async Task> ListAsync(CancellationToken cancellationToken) + { + var doc = await LoadAsync(cancellationToken).ConfigureAwait(false); + return doc.Providers + .OrderBy(static pair => pair.Key, StringComparer.OrdinalIgnoreCase) + .Select(static pair => new ProviderCredentialDescriptor( + pair.Key, + pair.Value.SourceType ?? "unknown", + pair.Value.EnvironmentVariableName, + pair.Value.UpdatedAtUtc)) + .ToArray(); + } + + /// + public async Task SetEnvironmentVariableAsync(string providerName, string environmentVariableName, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(providerName); + ArgumentException.ThrowIfNullOrWhiteSpace(environmentVariableName); + + var doc = await LoadAsync(cancellationToken).ConfigureAwait(false); + doc.Providers[providerName] = new StoredCredentialEntry( + SourceType: "env", + EnvironmentVariableName: environmentVariableName.Trim(), + ProtectedSecret: null, + UpdatedAtUtc: DateTimeOffset.UtcNow); + await SaveAsync(doc, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task SetProtectedSecretAsync(string providerName, string apiKey, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(providerName); + ArgumentException.ThrowIfNullOrWhiteSpace(apiKey); + if (!secretProtector.CanProtect) + { + throw new InvalidOperationException("Protected local secret storage is unavailable on this platform. Use --env-var instead."); + } + + var doc = await LoadAsync(cancellationToken).ConfigureAwait(false); + doc.Providers[providerName] = new StoredCredentialEntry( + SourceType: "protectedStore", + EnvironmentVariableName: null, + ProtectedSecret: secretProtector.Protect(apiKey.Trim()), + UpdatedAtUtc: DateTimeOffset.UtcNow); + await SaveAsync(doc, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task ClearAsync(string providerName, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(providerName); + var doc = await LoadAsync(cancellationToken).ConfigureAwait(false); + var removed = doc.Providers.Remove(providerName); + if (removed) + { + await SaveAsync(doc, cancellationToken).ConfigureAwait(false); + } + + return removed; + } + + private async Task LoadAsync(CancellationToken cancellationToken) + { + var path = GetPath(); + var text = await fileSystem.ReadAllTextIfExistsAsync(path, cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(text)) + { + return new StoredCredentialDocument(new Dictionary(StringComparer.OrdinalIgnoreCase)); + } + + return JsonSerializer.Deserialize(text, JsonOptions) + ?? new StoredCredentialDocument(new Dictionary(StringComparer.OrdinalIgnoreCase)); + } + + private Task SaveAsync(StoredCredentialDocument document, CancellationToken cancellationToken) + => fileSystem.WriteAllTextAsync(GetPath(), JsonSerializer.Serialize(document, JsonOptions), cancellationToken); + + private string GetPath() + => pathService.Combine(userProfilePaths.GetUserSharpClawRoot(), CredentialsFileName); + + private sealed record StoredCredentialDocument( + Dictionary Providers); + + private sealed record StoredCredentialEntry( + string? SourceType, + string? EnvironmentVariableName, + string? ProtectedSecret, + DateTimeOffset UpdatedAtUtc); +} diff --git a/src/SharpClaw.Code.Runtime/Abstractions/IEvolutionProposalService.cs b/src/SharpClaw.Code.Runtime/Abstractions/IEvolutionProposalService.cs new file mode 100644 index 0000000..604fa01 --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Abstractions/IEvolutionProposalService.cs @@ -0,0 +1,38 @@ +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Runtime.Abstractions; + +/// +/// Extracts, stores, and applies guided self-evolution proposals. +/// +public interface IEvolutionProposalService +{ + /// + /// Lists proposals for the workspace. + /// + Task> ListAsync(string workspaceRoot, CancellationToken cancellationToken); + + /// + /// Gets a proposal by id. + /// + Task GetAsync(string workspaceRoot, string proposalId, CancellationToken cancellationToken); + + /// + /// Analyzes workspace and session signals and updates durable proposals. + /// + Task> AnalyzeAsync(string workspaceRoot, string? sessionId, CancellationToken cancellationToken); + + /// + /// Applies one proposal after approval. + /// + Task ApplyAsync( + string workspaceRoot, + string proposalId, + RuntimeCommandContext context, + CancellationToken cancellationToken); + + /// + /// Rejects one proposal. + /// + Task RejectAsync(string workspaceRoot, string proposalId, string? rejectedBy, CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.Runtime/Abstractions/IResearchWorkflowService.cs b/src/SharpClaw.Code.Runtime/Abstractions/IResearchWorkflowService.cs new file mode 100644 index 0000000..9bde646 --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Abstractions/IResearchWorkflowService.cs @@ -0,0 +1,14 @@ +using SharpClaw.Code.Protocol.Commands; + +namespace SharpClaw.Code.Runtime.Abstractions; + +/// +/// Executes research-mode prompt flows through the standard runtime. +/// +public interface IResearchWorkflowService +{ + /// + /// Runs a research-mode prompt. + /// + Task ExecuteAsync(string prompt, RuntimeCommandContext context, CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.Runtime/Abstractions/IScheduledPromptService.cs b/src/SharpClaw.Code.Runtime/Abstractions/IScheduledPromptService.cs new file mode 100644 index 0000000..b234ddb --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Abstractions/IScheduledPromptService.cs @@ -0,0 +1,45 @@ +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Runtime.Abstractions; + +/// +/// Manages durable scheduled prompts and executes due work through the standard runtime. +/// +public interface IScheduledPromptService +{ + /// + /// Lists schedules for the workspace. + /// + Task> ListAsync(string workspaceRoot, CancellationToken cancellationToken); + + /// + /// Gets a schedule by id. + /// + Task GetAsync(string workspaceRoot, string scheduleId, CancellationToken cancellationToken); + + /// + /// Saves a schedule definition. + /// + Task SaveAsync(string workspaceRoot, ScheduledPromptDefinition definition, CancellationToken cancellationToken); + + /// + /// Deletes a schedule definition. + /// + Task RemoveAsync(string workspaceRoot, string scheduleId, CancellationToken cancellationToken); + + /// + /// Enables or disables a schedule. + /// + Task SetEnabledAsync(string workspaceRoot, string scheduleId, bool enabled, CancellationToken cancellationToken); + + /// + /// Executes one schedule immediately. + /// + Task RunAsync(string workspaceRoot, string scheduleId, RuntimeCommandContext context, CancellationToken cancellationToken); + + /// + /// Executes all due schedules for a workspace. + /// + Task> RunDueAsync(string workspaceRoot, RuntimeCommandContext context, CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.Runtime/Abstractions/ISessionPreferenceService.cs b/src/SharpClaw.Code.Runtime/Abstractions/ISessionPreferenceService.cs new file mode 100644 index 0000000..2eb1374 --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Abstractions/ISessionPreferenceService.cs @@ -0,0 +1,84 @@ +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Runtime.Abstractions; + +/// +/// Manages durable session-scoped control-plane preferences such as trust and model selection. +/// +public interface ISessionPreferenceService +{ + /// + /// Gets the effective permission/trust snapshot for a workspace session. + /// + Task GetPermissionStatusAsync( + string workspaceRoot, + string? sessionId, + PermissionMode fallbackPermissionMode, + ApprovalSettings? approvalSettings, + string? currentModel, + CancellationToken cancellationToken); + + /// + /// Grants durable session trust for one plugin or MCP server. + /// + Task GrantTrustAsync( + string workspaceRoot, + string? sessionId, + TrustedSourceKind kind, + string name, + PermissionMode fallbackPermissionMode, + ApprovalSettings? approvalSettings, + string? currentModel, + CancellationToken cancellationToken); + + /// + /// Revokes durable session trust for one plugin or MCP server. + /// + Task RevokeTrustAsync( + string workspaceRoot, + string? sessionId, + TrustedSourceKind kind, + string name, + PermissionMode fallbackPermissionMode, + ApprovalSettings? approvalSettings, + string? currentModel, + CancellationToken cancellationToken); + + /// + /// Persists the preferred model for a session. + /// + Task SetModelPreferenceAsync( + string workspaceRoot, + string? sessionId, + string model, + CancellationToken cancellationToken); + + /// + /// Clears the persisted model preference for a session. + /// + Task ClearModelPreferenceAsync(string workspaceRoot, string? sessionId, CancellationToken cancellationToken); + + /// + /// Persists the preferred permission mode for a session. + /// + Task SetPreferredPermissionModeAsync( + string workspaceRoot, + string? sessionId, + PermissionMode permissionMode, + CancellationToken cancellationToken); + + /// + /// Persists durable auto-approval settings for a session. + /// + Task SetApprovalSettingsAsync( + string workspaceRoot, + string? sessionId, + ApprovalSettings approvalSettings, + CancellationToken cancellationToken); + + /// + /// Clears durable auto-approval settings for a session. + /// + Task ClearApprovalSettingsAsync(string workspaceRoot, string? sessionId, CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.Runtime/Abstractions/IWorkspaceBootstrapService.cs b/src/SharpClaw.Code.Runtime/Abstractions/IWorkspaceBootstrapService.cs new file mode 100644 index 0000000..32ac16f --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Abstractions/IWorkspaceBootstrapService.cs @@ -0,0 +1,26 @@ +namespace SharpClaw.Code.Runtime.Abstractions; + +/// +/// Bootstraps minimal SharpClaw workspace files and directories. +/// +public interface IWorkspaceBootstrapService +{ + /// + /// Initializes the workspace SharpClaw layout. + /// + Task InitializeAsync( + string workspaceRoot, + bool force, + bool includeCommandsDirectory, + bool includeSkillsDirectory, + CancellationToken cancellationToken); +} + +/// +/// Result of initializing workspace-local SharpClaw scaffolding. +/// +public sealed record WorkspaceBootstrapResult( + string WorkspaceRoot, + string ConfigPath, + bool ConfigCreated, + string[] CreatedDirectories); diff --git a/src/SharpClaw.Code.Runtime/Composition/RuntimeServiceCollectionExtensions.cs b/src/SharpClaw.Code.Runtime/Composition/RuntimeServiceCollectionExtensions.cs index ba9a05e..27c3f7f 100644 --- a/src/SharpClaw.Code.Runtime/Composition/RuntimeServiceCollectionExtensions.cs +++ b/src/SharpClaw.Code.Runtime/Composition/RuntimeServiceCollectionExtensions.cs @@ -102,6 +102,12 @@ private static IServiceCollection AddSharpClawRuntimeCore( services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -120,6 +126,11 @@ private static IServiceCollection AddSharpClawRuntimeCore( services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -139,6 +150,7 @@ private static IServiceCollection AddSharpClawRuntimeCore( services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); services.AddHostedService(); + services.AddHostedService(); return services; } diff --git a/src/SharpClaw.Code.Runtime/Configuration/SharpClawConfigService.cs b/src/SharpClaw.Code.Runtime/Configuration/SharpClawConfigService.cs index ac5d433..316135b 100644 --- a/src/SharpClaw.Code.Runtime/Configuration/SharpClawConfigService.cs +++ b/src/SharpClaw.Code.Runtime/Configuration/SharpClawConfigService.cs @@ -32,10 +32,20 @@ public async Task GetConfigAsync(string workspaceRoot, var normalizedWorkspace = pathService.GetFullPath(workspaceRoot); var userConfigPath = GetUserConfigPath(); - var workspaceConfigPath = pathService.Combine(normalizedWorkspace, "sharpclaw.jsonc"); + var workspaceConfigPath = pathService.Combine(normalizedWorkspace, ".sharpclaw", "config.jsonc"); + var legacyWorkspaceConfigPath = pathService.Combine(normalizedWorkspace, "sharpclaw.jsonc"); var userDocument = await LoadDocumentAsync(userConfigPath, cancellationToken).ConfigureAwait(false); var workspaceDocument = await LoadDocumentAsync(workspaceConfigPath, cancellationToken).ConfigureAwait(false); + if (workspaceDocument is null) + { + workspaceDocument = await LoadDocumentAsync(legacyWorkspaceConfigPath, cancellationToken).ConfigureAwait(false); + if (workspaceDocument is not null) + { + workspaceConfigPath = legacyWorkspaceConfigPath; + } + } + var merged = Merge(userDocument, workspaceDocument); return new SharpClawConfigSnapshot( diff --git a/src/SharpClaw.Code.Runtime/Context/PromptContextAssembler.cs b/src/SharpClaw.Code.Runtime/Context/PromptContextAssembler.cs index 6cdaadd..afe14d2 100644 --- a/src/SharpClaw.Code.Runtime/Context/PromptContextAssembler.cs +++ b/src/SharpClaw.Code.Runtime/Context/PromptContextAssembler.cs @@ -67,6 +67,36 @@ public async Task AssembleAsync( ? new Dictionary(StringComparer.Ordinal) : new Dictionary(request.Metadata, StringComparer.Ordinal); + if (session.Metadata is not null) + { + if (session.Metadata.TryGetValue(SharpClawWorkflowMetadataKeys.TrustedPluginNamesJson, out var trustedPluginsJson) + && !string.IsNullOrWhiteSpace(trustedPluginsJson)) + { + metadata[SharpClawWorkflowMetadataKeys.TrustedPluginNamesJson] = trustedPluginsJson; + } + + if (session.Metadata.TryGetValue(SharpClawWorkflowMetadataKeys.TrustedMcpServerNamesJson, out var trustedMcpJson) + && !string.IsNullOrWhiteSpace(trustedMcpJson)) + { + metadata[SharpClawWorkflowMetadataKeys.TrustedMcpServerNamesJson] = trustedMcpJson; + } + + if (session.Metadata.TryGetValue(SharpClawWorkflowMetadataKeys.PreferredPermissionMode, out var preferredPermissionMode) + && !string.IsNullOrWhiteSpace(preferredPermissionMode)) + { + metadata[SharpClawWorkflowMetadataKeys.PreferredPermissionMode] = preferredPermissionMode; + } + } + + if (!metadata.ContainsKey("model") + && session.Metadata is not null + && session.Metadata.TryGetValue(SharpClawWorkflowMetadataKeys.SessionModelPreferenceJson, out var storedModelPreference) + && TryReadSessionModelPreference(storedModelPreference) is { } preferredModel + && !string.IsNullOrWhiteSpace(preferredModel)) + { + metadata["model"] = preferredModel; + } + if (!metadata.ContainsKey("model") && memoryContext.RepositorySettings.TryGetValue("defaultModel", out var defaultModel) && !string.IsNullOrWhiteSpace(defaultModel)) @@ -212,9 +242,21 @@ public async Task AssembleAsync( { sections.Add(specWorkflowService.BuildPromptInstructions()); } + else if (effectivePrimary == Protocol.Enums.PrimaryMode.Research) + { + sections.Add( + """ + Research mode is active. + + Prefer explicit citations, confidence notes, and unresolved questions. + Use read-only investigation tools only. Distinguish confirmed findings from inference. + """); + } sections.Add($"User request:\n{refResolution.ExpandedPrompt}"); + var finalPrompt = string.Join(Environment.NewLine + Environment.NewLine, sections); + // Prefer cached history for the previous turn when available; on a cache miss, // fall back to reading the full event log and re-assembling the history for caching. // The fallback path still scales linearly with session length for long-running sessions. @@ -234,9 +276,10 @@ public async Task AssembleAsync( } return new PromptExecutionContext( - Prompt: string.Join(Environment.NewLine + Environment.NewLine, sections), + Prompt: finalPrompt, Metadata: metadata, - ConversationHistory: conversationHistory); + ConversationHistory: conversationHistory, + UserContent: BuildUserContent(finalPrompt, refResolution)); } private static string RenderInstructionRules(InstructionRuleSnapshot snapshot) @@ -250,4 +293,32 @@ private static string RenderInstructionRules(InstructionRuleSnapshot snapshot) return string.Join(Environment.NewLine, lines); } + + private static string? TryReadSessionModelPreference(string payload) + { + try + { + var preference = JsonSerializer.Deserialize(payload, ProtocolJsonContext.Default.SessionModelPreference); + return string.IsNullOrWhiteSpace(preference?.Model) ? null : preference.Model; + } + catch (JsonException) + { + return null; + } + } + + private static IReadOnlyList BuildUserContent(string prompt, PromptReferenceResolution resolution) + { + var blocks = new List + { + new(ContentBlockKind.Text, prompt, null, null, null, null) + }; + + if (resolution.StructuredContent is { Count: > 0 }) + { + blocks.AddRange(resolution.StructuredContent); + } + + return blocks; + } } diff --git a/src/SharpClaw.Code.Runtime/Context/PromptExecutionContext.cs b/src/SharpClaw.Code.Runtime/Context/PromptExecutionContext.cs index ddd7982..619e7d0 100644 --- a/src/SharpClaw.Code.Runtime/Context/PromptExecutionContext.cs +++ b/src/SharpClaw.Code.Runtime/Context/PromptExecutionContext.cs @@ -11,7 +11,9 @@ namespace SharpClaw.Code.Runtime.Context; /// Prior turn messages assembled from session events, ready to be prepended to the /// provider request. May be empty for a brand-new session. /// +/// Optional structured user content blocks for the current turn. public sealed record PromptExecutionContext( string Prompt, IReadOnlyDictionary Metadata, - IReadOnlyList? ConversationHistory = null); + IReadOnlyList? ConversationHistory = null, + IReadOnlyList? UserContent = null); diff --git a/src/SharpClaw.Code.Runtime/Prompts/PromptReferenceResolver.cs b/src/SharpClaw.Code.Runtime/Prompts/PromptReferenceResolver.cs index 36394d7..777e4c9 100644 --- a/src/SharpClaw.Code.Runtime/Prompts/PromptReferenceResolver.cs +++ b/src/SharpClaw.Code.Runtime/Prompts/PromptReferenceResolver.cs @@ -20,6 +20,17 @@ public sealed partial class PromptReferenceResolver( IPermissionPolicyEngine permissionPolicyEngine, IRuntimeHostContextAccessor? hostContextAccessor = null) : IPromptReferenceResolver { + private const int MaxDirectoryReferenceFiles = 20; + private const int MaxDirectoryReferenceBytes = 200 * 1024; + private static readonly HashSet ImageExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".png", ".jpg", ".jpeg", ".gif", ".webp" + }; + private static readonly HashSet BinaryExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".pdf", ".zip", ".gz", ".tar", ".dll", ".exe", ".so", ".dylib", ".bin" + }; + /// public async Task ResolveAsync( string workspaceRoot, @@ -45,6 +56,7 @@ public async Task ResolveAsync( var workDirFull = pathService.GetCanonicalFullPath(workingDirectory); var refs = new List(); var expanded = new StringBuilder(original); + var structuredContent = new List(); foreach (var match in matches.OrderByDescending(m => m.Index)) { @@ -78,34 +90,28 @@ request.Metadata is not null cancellationToken).ConfigureAwait(false); } - var text = await fileSystem.ReadAllTextIfExistsAsync(resolvedFull, cancellationToken).ConfigureAwait(false); - if (text is null) - { - throw new InvalidOperationException($"Referenced path is missing or unreadable: '{resolvedFull}'."); - } - var display = ToDisplayPath(workspaceFull, workDirFull, resolvedFull); - var block = - $"[Referenced file: {display}]" + Environment.NewLine - + text - + Environment.NewLine - + $"[End referenced file: {display}]"; + var (block, promptReference, extraContent) = await ResolveReferenceAsync( + resolvedFull, + display, + rawToken, + pathPart, + outside, + cancellationToken) + .ConfigureAwait(false); expanded.Remove(match.Index, match.Length); expanded.Insert(match.Index, block); - - refs.Add(new PromptReference( - Kind: PromptReferenceKind.File, - OriginalToken: rawToken, - RequestedPath: pathPart, - ResolvedFullPath: resolvedFull, - DisplayPath: display, - WasOutsideWorkspace: outside, - IncludedContent: text)); + refs.Add(promptReference); + if (extraContent is not null) + { + structuredContent.Add(extraContent); + } } refs.Reverse(); - return new PromptReferenceResolution(original, expanded.ToString(), refs); + structuredContent.Reverse(); + return new PromptReferenceResolution(original, expanded.ToString(), refs, structuredContent); } private async Task EnsureOutsideWorkspaceAllowedAsync( @@ -197,6 +203,172 @@ private static string ToDisplayPath(string workspaceRootFull, string workingDire return fullPath; } + private async Task<(string ExpandedText, PromptReference Reference, ContentBlock? StructuredContent)> ResolveReferenceAsync( + string resolvedFull, + string display, + string rawToken, + string pathPart, + bool outsideWorkspace, + CancellationToken cancellationToken) + { + if (Directory.Exists(resolvedFull)) + { + var (rendered, count) = await RenderDirectoryReferenceAsync(resolvedFull, display, cancellationToken).ConfigureAwait(false); + return ( + rendered, + new PromptReference( + PromptReferenceKind.Directory, + rawToken, + pathPart, + resolvedFull, + display, + outsideWorkspace, + rendered, + IncludedEntryCount: count), + null); + } + + if (ImageExtensions.Contains(Path.GetExtension(resolvedFull))) + { + var bytes = await File.ReadAllBytesAsync(resolvedFull, cancellationToken).ConfigureAwait(false); + var mediaType = ResolveMediaType(resolvedFull); + var placeholder = + $"[Referenced image: {display} ({mediaType})]" + Environment.NewLine + + $"[End referenced image: {display}]"; + return ( + placeholder, + new PromptReference( + PromptReferenceKind.Image, + rawToken, + pathPart, + resolvedFull, + display, + outsideWorkspace, + placeholder, + MediaType: mediaType, + IncludedEntryCount: 1), + new ContentBlock( + ContentBlockKind.Image, + Text: display, + ToolUseId: null, + ToolName: null, + ToolInputJson: null, + IsError: null, + MediaType: mediaType, + Data: Convert.ToBase64String(bytes), + Uri: resolvedFull)); + } + + var text = await fileSystem.ReadAllTextIfExistsAsync(resolvedFull, cancellationToken).ConfigureAwait(false); + if (text is null) + { + throw new InvalidOperationException($"Referenced path is missing or unreadable: '{resolvedFull}'."); + } + + return ( + $"[Referenced file: {display}]" + Environment.NewLine + + text + + Environment.NewLine + + $"[End referenced file: {display}]", + new PromptReference( + PromptReferenceKind.File, + rawToken, + pathPart, + resolvedFull, + display, + outsideWorkspace, + text, + IncludedEntryCount: 1), + null); + } + + private static async Task<(string Rendered, int FileCount)> RenderDirectoryReferenceAsync( + string directoryPath, + string display, + CancellationToken cancellationToken) + { + var files = Directory.EnumerateFiles(directoryPath, "*", SearchOption.AllDirectories) + .Where(static path => !ShouldSkipPath(path)) + .OrderBy(static path => path, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var included = new List<(string RelativePath, string Content)>(); + var totalBytes = 0; + foreach (var file in files) + { + cancellationToken.ThrowIfCancellationRequested(); + if (included.Count >= MaxDirectoryReferenceFiles) + { + break; + } + + if (BinaryExtensions.Contains(Path.GetExtension(file))) + { + continue; + } + + string text; + try + { + text = await File.ReadAllTextAsync(file, cancellationToken).ConfigureAwait(false); + } + catch + { + continue; + } + + if (text.IndexOf('\0') >= 0) + { + continue; + } + + var bytes = Encoding.UTF8.GetByteCount(text); + if (totalBytes + bytes > MaxDirectoryReferenceBytes) + { + break; + } + + totalBytes += bytes; + included.Add((Path.GetRelativePath(directoryPath, file).Replace(Path.DirectorySeparatorChar, '/'), text)); + } + + var builder = new StringBuilder(); + builder.Append("[Referenced directory: ").Append(display).AppendLine("]"); + builder.AppendLine("Manifest:"); + foreach (var entry in included) + { + builder.Append("- ").AppendLine(entry.RelativePath); + } + + foreach (var entry in included) + { + builder.AppendLine() + .Append("[Referenced file: ") + .Append(entry.RelativePath) + .AppendLine("]") + .AppendLine(entry.Content) + .Append("[End referenced file: ") + .Append(entry.RelativePath) + .AppendLine("]"); + } + + builder.Append("[End referenced directory: ").Append(display).Append(']'); + return (builder.ToString(), included.Count); + } + + private static bool ShouldSkipPath(string path) + => path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + .Any(static segment => segment is ".git" or ".sharpclaw" or "bin" or "obj"); + + private static string ResolveMediaType(string path) + => Path.GetExtension(path).ToLowerInvariant() switch + { + ".png" => "image/png", + ".gif" => "image/gif", + ".webp" => "image/webp", + _ => "image/jpeg", + }; + [GeneratedRegex(@"@([^\s<>""|*?]+)", RegexOptions.CultureInvariant)] private static partial Regex AtPathRegex(); } diff --git a/src/SharpClaw.Code.Runtime/Turns/DefaultTurnRunner.cs b/src/SharpClaw.Code.Runtime/Turns/DefaultTurnRunner.cs index f959a96..a6da681 100644 --- a/src/SharpClaw.Code.Runtime/Turns/DefaultTurnRunner.cs +++ b/src/SharpClaw.Code.Runtime/Turns/DefaultTurnRunner.cs @@ -59,7 +59,8 @@ public async Task RunAsync( DelegatedTask: request.DelegatedTask, ConversationHistory: promptContext.ConversationHistory, IsInteractive: request.IsInteractive, - ApprovalSettings: request.ApprovalSettings); + ApprovalSettings: request.ApprovalSettings, + UserContent: promptContext.UserContent); using var turnScope = new TurnActivityScope(session.Id, turn.Id, promptContext.Prompt); var sw = Stopwatch.StartNew(); diff --git a/src/SharpClaw.Code.Runtime/Workflow/EvolutionProposalService.cs b/src/SharpClaw.Code.Runtime/Workflow/EvolutionProposalService.cs new file mode 100644 index 0000000..53240d4 --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Workflow/EvolutionProposalService.cs @@ -0,0 +1,412 @@ +using System.Text; +using System.Text.Json; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Permissions.Abstractions; +using SharpClaw.Code.Permissions.Models; +using SharpClaw.Code.Protocol.Abstractions; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Events; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Runtime.Abstractions; +using SharpClaw.Code.Sessions.Abstractions; + +namespace SharpClaw.Code.Runtime.Workflow; + +/// +public sealed class EvolutionProposalService( + IEvolutionProposalStore proposalStore, + ISessionStore sessionStore, + IEventStore eventStore, + IWorkspaceInsightsService workspaceInsightsService, + IProjectMemoryService projectMemoryService, + ISessionCoordinator sessionCoordinator, + ISessionPreferenceService sessionPreferenceService, + ISpecWorkflowService specWorkflowService, + IPermissionPolicyEngine permissionPolicyEngine, + IFileSystem fileSystem, + IPathService pathService, + ISystemClock systemClock) : IEvolutionProposalService +{ + /// + public Task> ListAsync(string workspaceRoot, CancellationToken cancellationToken) + => proposalStore.ListAsync(pathService.GetFullPath(workspaceRoot), cancellationToken); + + /// + public Task GetAsync(string workspaceRoot, string proposalId, CancellationToken cancellationToken) + => proposalStore.GetByIdAsync(pathService.GetFullPath(workspaceRoot), proposalId, cancellationToken); + + /// + public async Task> AnalyzeAsync( + string workspaceRoot, + string? sessionId, + CancellationToken cancellationToken) + { + var normalizedWorkspace = pathService.GetFullPath(workspaceRoot); + var existing = (await proposalStore.ListAsync(normalizedWorkspace, cancellationToken).ConfigureAwait(false)) + .ToDictionary(static item => item.Id, StringComparer.Ordinal); + var currentSessionId = sessionId + ?? await sessionCoordinator.GetAttachedSessionIdAsync(normalizedWorkspace, cancellationToken).ConfigureAwait(false); + var stats = await workspaceInsightsService.BuildStatsReportAsync(normalizedWorkspace, currentSessionId, cancellationToken).ConfigureAwait(false); + var memoryContext = await projectMemoryService.BuildContextAsync(normalizedWorkspace, cancellationToken).ConfigureAwait(false); + var sessions = await sessionStore.ListAllAsync(normalizedWorkspace, cancellationToken).ConfigureAwait(false); + + var failedTurns = 0; + var permissionDenials = 0; + var toolFailures = 0; + foreach (var session in sessions) + { + var events = await eventStore.ReadAllAsync(normalizedWorkspace, session.Id, cancellationToken).ConfigureAwait(false); + failedTurns += events.OfType().Count(static item => !item.Succeeded); + permissionDenials += events.OfType().Count(static item => !item.Decision.IsAllowed); + toolFailures += events.OfType().Count(static item => !item.Result.Succeeded); + } + + var candidates = new List(); + if (memoryContext.Memory is null) + { + candidates.Add(BuildProposal( + "evolution-knowledge-refresh", + normalizedWorkspace, + EvolutionProposalCategory.KnowledgeRefresh, + "Create project memory", + "The workspace has no durable SharpClaw memory document, so repeated expectations are likely to be relearned every session.", + [ + "No .sharpclaw/SHARPCLAW.md document was found.", + $"{stats.SessionCount} persisted session(s) already exist for this workspace." + ], + [ + "Create .sharpclaw/SHARPCLAW.md with the current delivery rules, architecture boundaries, and operator preferences." + ])); + } + + if (permissionDenials >= 2) + { + candidates.Add(BuildProposal( + "evolution-approval-defaults", + normalizedWorkspace, + EvolutionProposalCategory.ApprovalDefaults, + "Tighten approval defaults to a durable session preference", + "Permission denials are recurring often enough that the workspace should pin an explicit session default instead of relying on ad hoc retries.", + [ + $"{permissionDenials} permission denial event(s) were recorded across persisted sessions.", + $"{stats.ProviderRequestCount} provider request(s) and {stats.ToolExecutionCount} tool execution(s) were observed." + ], + [ + "Persist workspaceWrite as the preferred session permission mode for the active session." + ])); + } + + if (failedTurns >= 2 || toolFailures >= 3) + { + candidates.Add(BuildProposal( + "evolution-prompt-policy", + normalizedWorkspace, + EvolutionProposalCategory.PromptPolicy, + "Append a sharper delivery policy to project memory", + "Repeated failed turns or tool failures suggest the agent needs tighter local execution guidance that survives across sessions.", + [ + $"{failedTurns} failed turn(s) were recorded.", + $"{toolFailures} failed tool execution(s) were recorded." + ], + [ + "Append a short policy section instructing the agent to prefer smaller reversible steps, explicit assumptions, and immediate failure reporting." + ])); + } + + if (failedTurns >= 3) + { + candidates.Add(BuildProposal( + "evolution-code-spec", + normalizedWorkspace, + EvolutionProposalCategory.CodeSpec, + "Materialize a recovery spec for unstable workflows", + "The workspace has enough repeated failures that the next iteration should be driven by a spec artifact instead of another unconstrained execution loop.", + [ + $"{failedTurns} failed turn(s) were recorded.", + $"{stats.ActiveTodoCount} active todo item(s) remain open." + ], + [ + "Generate a spec artifact set covering failure modes, guardrails, and the next implementation slice." + ])); + } + + foreach (var candidate in candidates) + { + if (existing.TryGetValue(candidate.Id, out var prior) + && prior.Status is EvolutionProposalStatus.Applied or EvolutionProposalStatus.Rejected) + { + continue; + } + + await proposalStore.SaveAsync(normalizedWorkspace, candidate, cancellationToken).ConfigureAwait(false); + existing[candidate.Id] = candidate; + } + + return existing.Values + .OrderByDescending(static item => item.UpdatedAtUtc ?? item.CreatedAtUtc) + .ToArray(); + } + + /// + public async Task ApplyAsync( + string workspaceRoot, + string proposalId, + RuntimeCommandContext context, + CancellationToken cancellationToken) + { + var normalizedWorkspace = pathService.GetFullPath(workspaceRoot); + var proposal = await proposalStore.GetByIdAsync(normalizedWorkspace, proposalId, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Evolution proposal '{proposalId}' was not found."); + if (proposal.Status != EvolutionProposalStatus.Open) + { + throw new InvalidOperationException($"Evolution proposal '{proposalId}' is already {proposal.Status}."); + } + + await RequireApprovalAsync(normalizedWorkspace, proposal, context, cancellationToken).ConfigureAwait(false); + + var now = systemClock.UtcNow; + var updated = proposal.Category switch + { + EvolutionProposalCategory.ApprovalDefaults => await ApplyApprovalDefaultsAsync(normalizedWorkspace, proposal, context, now, cancellationToken).ConfigureAwait(false), + EvolutionProposalCategory.PromptPolicy => await ApplyPromptPolicyAsync(normalizedWorkspace, proposal, context, now, cancellationToken).ConfigureAwait(false), + EvolutionProposalCategory.KnowledgeRefresh => await ApplyKnowledgeRefreshAsync(normalizedWorkspace, proposal, context, now, cancellationToken).ConfigureAwait(false), + EvolutionProposalCategory.CodeSpec => await ApplyCodeSpecAsync(normalizedWorkspace, proposal, context, now, cancellationToken).ConfigureAwait(false), + EvolutionProposalCategory.ModelRouting => throw new InvalidOperationException("Model-routing proposals are not generated automatically yet. Set an explicit model with /model use."), + EvolutionProposalCategory.SkillSuggestion => throw new InvalidOperationException("Skill suggestion proposals are advisory only in this build."), + EvolutionProposalCategory.PluginSuggestion => throw new InvalidOperationException("Plugin suggestion proposals are advisory only in this build."), + _ => throw new InvalidOperationException($"Unsupported evolution proposal category '{proposal.Category}'."), + }; + + await proposalStore.SaveAsync(normalizedWorkspace, updated, cancellationToken).ConfigureAwait(false); + return updated; + } + + /// + public async Task RejectAsync( + string workspaceRoot, + string proposalId, + string? rejectedBy, + CancellationToken cancellationToken) + { + var normalizedWorkspace = pathService.GetFullPath(workspaceRoot); + var proposal = await proposalStore.GetByIdAsync(normalizedWorkspace, proposalId, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Evolution proposal '{proposalId}' was not found."); + var updated = proposal with + { + Status = EvolutionProposalStatus.Rejected, + UpdatedAtUtc = systemClock.UtcNow, + AppliedBy = string.IsNullOrWhiteSpace(rejectedBy) ? proposal.AppliedBy : rejectedBy, + }; + await proposalStore.SaveAsync(normalizedWorkspace, updated, cancellationToken).ConfigureAwait(false); + return updated; + } + + private async Task RequireApprovalAsync( + string workspaceRoot, + EvolutionProposal proposal, + RuntimeCommandContext context, + CancellationToken cancellationToken) + { + var scope = proposal.Category is EvolutionProposalCategory.PromptPolicy + or EvolutionProposalCategory.KnowledgeRefresh + or EvolutionProposalCategory.CodeSpec + ? ApprovalScope.FileSystemWrite + : ApprovalScope.SessionOperation; + var sessionId = context.SessionId + ?? await sessionCoordinator.GetAttachedSessionIdAsync(workspaceRoot, cancellationToken).ConfigureAwait(false) + ?? "evolution-workspace"; + var request = new ToolExecutionRequest( + Id: $"evolution-{proposal.Id}", + SessionId: sessionId, + TurnId: "evolution-apply", + ToolName: $"evolution.apply.{proposal.Category}", + ArgumentsJson: JsonSerializer.Serialize(new Dictionary + { + ["proposalId"] = proposal.Id, + ["category"] = proposal.Category.ToString() + }), + ApprovalScope: scope, + WorkingDirectory: workspaceRoot, + RequiresApproval: true, + IsDestructive: true); + var evaluationContext = new PermissionEvaluationContext( + SessionId: sessionId, + WorkspaceRoot: workspaceRoot, + WorkingDirectory: workspaceRoot, + PermissionMode: context.PermissionMode, + AllowedTools: null, + AllowDangerousBypass: false, + IsInteractive: context.IsInteractive, + SourceKind: PermissionRequestSourceKind.Runtime, + SourceName: "evolution", + TrustedPluginNames: null, + TrustedMcpServerNames: null, + ToolOriginatingPluginId: null, + ToolOriginatingPluginTrust: null, + PrimaryMode: context.PrimaryMode ?? PrimaryMode.Build, + TenantId: context.HostContext?.TenantId, + ApprovalSettings: context.ApprovalSettings); + var decision = await permissionPolicyEngine.EvaluateAsync(request, evaluationContext, cancellationToken).ConfigureAwait(false); + if (!decision.IsAllowed) + { + throw new InvalidOperationException(decision.Reason ?? $"Approval was denied for proposal '{proposal.Id}'."); + } + } + + private async Task ApplyApprovalDefaultsAsync( + string workspaceRoot, + EvolutionProposal proposal, + RuntimeCommandContext context, + DateTimeOffset now, + CancellationToken cancellationToken) + { + await sessionPreferenceService + .SetPreferredPermissionModeAsync(workspaceRoot, context.SessionId, PermissionMode.WorkspaceWrite, cancellationToken) + .ConfigureAwait(false); + return MarkApplied(proposal, context, now, "Persisted workspaceWrite as the preferred session permission mode."); + } + + private async Task ApplyPromptPolicyAsync( + string workspaceRoot, + EvolutionProposal proposal, + RuntimeCommandContext context, + DateTimeOffset now, + CancellationToken cancellationToken) + { + await AppendProjectMemorySectionAsync( + workspaceRoot, + "Execution policy", + [ + "Prefer smaller reversible implementation steps.", + "State assumptions explicitly before relying on them.", + "Report suspected bugs immediately instead of silently correcting them." + ], + cancellationToken).ConfigureAwait(false); + return MarkApplied(proposal, context, now, "Appended an execution-policy section to .sharpclaw/SHARPCLAW.md."); + } + + private async Task ApplyKnowledgeRefreshAsync( + string workspaceRoot, + EvolutionProposal proposal, + RuntimeCommandContext context, + DateTimeOffset now, + CancellationToken cancellationToken) + { + await AppendProjectMemorySectionAsync( + workspaceRoot, + "Project memory", + [ + "Document architecture boundaries, delivery expectations, and common failure modes here.", + "Keep this file current when operator preferences or runtime policies change." + ], + cancellationToken).ConfigureAwait(false); + return MarkApplied(proposal, context, now, "Created or refreshed .sharpclaw/SHARPCLAW.md."); + } + + private async Task ApplyCodeSpecAsync( + string workspaceRoot, + EvolutionProposal proposal, + RuntimeCommandContext context, + DateTimeOffset now, + CancellationToken cancellationToken) + { + var payload = JsonSerializer.Serialize(new + { + requirements = new + { + title = proposal.Title, + summary = proposal.Summary, + requirements = proposal.Evidence.Select((evidence, index) => new + { + id = $"REQ-{index + 1:000}", + statement = $"When the current workspace signals recur, the system shall address {evidence.ToLowerInvariant()}.", + rationale = "Generated from workspace evolution analysis." + }).ToArray() + }, + design = new + { + title = $"{proposal.Title} Design", + summary = proposal.Summary, + architecture = proposal.RecommendedActions, + dataFlow = proposal.Evidence, + interfaces = new[] { "Runtime command service", "Session metadata", "Project memory" }, + failureModes = new[] { "Repeated failed turns should become explicit recovery work." }, + testing = new[] { "Review the generated spec before the next implementation pass." } + }, + tasks = new + { + title = $"{proposal.Title} Tasks", + tasks = proposal.RecommendedActions.Select((action, index) => new + { + id = $"TASK-{index + 1:000}", + description = action, + doneCriteria = "The change is reflected in the workspace and no longer repeats in evolution analysis." + }).ToArray() + } + }); + var artifacts = await specWorkflowService + .MaterializeAsync(workspaceRoot, proposal.Title, payload, cancellationToken) + .ConfigureAwait(false); + return MarkApplied(proposal, context, now, $"Materialized recovery spec artifacts at {artifacts.RootPath}."); + } + + private async Task AppendProjectMemorySectionAsync( + string workspaceRoot, + string heading, + IReadOnlyList lines, + CancellationToken cancellationToken) + { + var memoryRoot = pathService.Combine(workspaceRoot, ".sharpclaw"); + var memoryPath = pathService.Combine(memoryRoot, "SHARPCLAW.md"); + fileSystem.CreateDirectory(memoryRoot); + + var existing = await fileSystem.ReadAllTextIfExistsAsync(memoryPath, cancellationToken).ConfigureAwait(false); + var builder = new StringBuilder(string.IsNullOrWhiteSpace(existing) ? string.Empty : existing!.TrimEnd() + Environment.NewLine + Environment.NewLine); + if (!string.IsNullOrWhiteSpace(existing) && existing.Contains($"## {heading}", StringComparison.Ordinal)) + { + return; + } + + builder.Append("## ").AppendLine(heading).AppendLine(); + foreach (var line in lines) + { + builder.Append("- ").AppendLine(line); + } + + await fileSystem.WriteAllTextAsync(memoryPath, builder.ToString().TrimEnd() + Environment.NewLine, cancellationToken).ConfigureAwait(false); + } + + private EvolutionProposal BuildProposal( + string id, + string workspaceRoot, + EvolutionProposalCategory category, + string title, + string summary, + string[] evidence, + string[] actions) + => new( + Id: id, + WorkspaceRoot: workspaceRoot, + Category: category, + Status: EvolutionProposalStatus.Open, + Title: title, + Summary: summary, + Evidence: evidence, + RecommendedActions: actions, + CreatedAtUtc: systemClock.UtcNow, + UpdatedAtUtc: systemClock.UtcNow); + + private static EvolutionProposal MarkApplied( + EvolutionProposal proposal, + RuntimeCommandContext context, + DateTimeOffset now, + string actionNote) + => proposal with + { + Status = EvolutionProposalStatus.Applied, + UpdatedAtUtc = now, + AppliedBy = context.AgentId ?? "cli", + RecommendedActions = proposal.RecommendedActions.Concat([actionNote]).ToArray(), + }; +} diff --git a/src/SharpClaw.Code.Runtime/Workflow/ResearchWorkflowService.cs b/src/SharpClaw.Code.Runtime/Workflow/ResearchWorkflowService.cs new file mode 100644 index 0000000..191116c --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Workflow/ResearchWorkflowService.cs @@ -0,0 +1,31 @@ +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Runtime.Abstractions; + +namespace SharpClaw.Code.Runtime.Workflow; + +/// +public sealed class ResearchWorkflowService( + IRuntimeCommandService runtimeCommandService) : IResearchWorkflowService +{ + private const string ResearchPrefix = """ + Research mode is active. + + Produce a citation-oriented answer with: + - concise findings + - clearly attributed sources + - confidence notes where uncertainty remains + - unresolved questions when evidence is incomplete + """; + + /// + public Task ExecuteAsync(string prompt, RuntimeCommandContext context, CancellationToken cancellationToken) + => runtimeCommandService.ExecutePromptAsync( + $"{ResearchPrefix}{Environment.NewLine}{Environment.NewLine}{prompt.Trim()}", + context with + { + PrimaryMode = PrimaryMode.Research, + PermissionMode = PermissionMode.ReadOnly, + }, + cancellationToken); +} diff --git a/src/SharpClaw.Code.Runtime/Workflow/ScheduleCronExpression.cs b/src/SharpClaw.Code.Runtime/Workflow/ScheduleCronExpression.cs new file mode 100644 index 0000000..442c520 --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Workflow/ScheduleCronExpression.cs @@ -0,0 +1,135 @@ +namespace SharpClaw.Code.Runtime.Workflow; + +internal static class ScheduleCronExpression +{ + public static DateTimeOffset GetNextOccurrence(string expression, DateTimeOffset from) + { + ArgumentException.ThrowIfNullOrWhiteSpace(expression); + + var normalized = expression.Trim(); + if (string.Equals(normalized, "@hourly", StringComparison.OrdinalIgnoreCase)) + { + var candidate = new DateTimeOffset(from.Year, from.Month, from.Day, from.Hour, 0, 0, TimeSpan.Zero).AddHours(1); + return candidate > from ? candidate : candidate.AddHours(1); + } + + if (string.Equals(normalized, "@daily", StringComparison.OrdinalIgnoreCase)) + { + var candidate = new DateTimeOffset(from.Year, from.Month, from.Day, 0, 0, 0, TimeSpan.Zero).AddDays(1); + return candidate > from ? candidate : candidate.AddDays(1); + } + + if (string.Equals(normalized, "@weekly", StringComparison.OrdinalIgnoreCase)) + { + var start = new DateTimeOffset(from.Year, from.Month, from.Day, 0, 0, 0, TimeSpan.Zero); + var daysUntilMonday = ((int)DayOfWeek.Monday - (int)start.DayOfWeek + 7) % 7; + if (daysUntilMonday == 0) + { + daysUntilMonday = 7; + } + + return start.AddDays(daysUntilMonday); + } + + var parts = normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length != 5) + { + throw new InvalidOperationException("Schedule cron expressions must use five fields or one of @hourly, @daily, or @weekly."); + } + + var minute = ParseField(parts[0], 0, 59, "minute"); + var hour = ParseField(parts[1], 0, 23, "hour"); + var day = parts[2]; + var month = parts[3]; + var dayOfWeek = parts[4]; + + if (!string.Equals(day, "*", StringComparison.Ordinal) + || !string.Equals(month, "*", StringComparison.Ordinal)) + { + throw new InvalidOperationException("Only '*' is currently supported for day-of-month and month in scheduled prompt cron expressions."); + } + + var cursor = from.ToUniversalTime().AddMinutes(1); + cursor = new DateTimeOffset(cursor.Year, cursor.Month, cursor.Day, cursor.Hour, cursor.Minute, 0, TimeSpan.Zero); + + for (var i = 0; i < 525600; i++) + { + if (Matches(hour, cursor.Hour) && Matches(minute, cursor.Minute) && MatchesDayOfWeek(dayOfWeek, cursor.DayOfWeek)) + { + return cursor; + } + + cursor = cursor.AddMinutes(1); + } + + throw new InvalidOperationException($"Unable to compute the next occurrence for cron expression '{expression}'."); + } + + private static CronField ParseField(string token, int min, int max, string fieldName) + { + if (string.Equals(token, "*", StringComparison.Ordinal)) + { + return new CronField(null, null, isWildcard: true); + } + + if (token.StartsWith("*/", StringComparison.Ordinal)) + { + if (!int.TryParse(token[2..], out var step) || step <= 0) + { + throw new InvalidOperationException($"Invalid {fieldName} step expression '{token}'."); + } + + return new CronField(null, step, isWildcard: false); + } + + if (!int.TryParse(token, out var value) || value < min || value > max) + { + throw new InvalidOperationException($"Invalid {fieldName} value '{token}'."); + } + + return new CronField(value, null, isWildcard: false); + } + + private static bool Matches(CronField field, int value) + { + if (field.IsWildcard) + { + return true; + } + + if (field.Step is { } step) + { + return value % step == 0; + } + + return field.Value == value; + } + + private static bool MatchesDayOfWeek(string token, DayOfWeek value) + { + if (string.Equals(token, "*", StringComparison.Ordinal)) + { + return true; + } + + if (int.TryParse(token, out var numeric)) + { + numeric = numeric == 7 ? 0 : numeric; + return (int)value == numeric; + } + + return token.Trim().ToLowerInvariant() switch + { + "sun" => value == DayOfWeek.Sunday, + "mon" => value == DayOfWeek.Monday, + "tue" or "tues" => value == DayOfWeek.Tuesday, + "wed" => value == DayOfWeek.Wednesday, + "thu" or "thur" or "thurs" => value == DayOfWeek.Thursday, + "fri" => value == DayOfWeek.Friday, + "sat" => value == DayOfWeek.Saturday, + _ => throw new InvalidOperationException($"Invalid day-of-week value '{token}'."), + }; + } + + private readonly record struct CronField(int? Value, int? Step, bool IsWildcard); +} diff --git a/src/SharpClaw.Code.Runtime/Workflow/ScheduledPromptRunner.cs b/src/SharpClaw.Code.Runtime/Workflow/ScheduledPromptRunner.cs new file mode 100644 index 0000000..bca6c74 --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Workflow/ScheduledPromptRunner.cs @@ -0,0 +1,61 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Runtime.Abstractions; + +namespace SharpClaw.Code.Runtime.Workflow; + +/// +/// Polls due scheduled prompts for the current workspace process. +/// +public sealed class ScheduledPromptRunner( + IScheduledPromptService scheduledPromptService, + IPathService pathService, + ILogger logger) : BackgroundService +{ + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var workspaceRoot = pathService.GetFullPath(pathService.GetCurrentDirectory()); + var timer = new PeriodicTimer(TimeSpan.FromSeconds(30)); + try + { + do + { + try + { + await scheduledPromptService.RunDueAsync( + workspaceRoot, + new RuntimeCommandContext( + WorkingDirectory: workspaceRoot, + Model: null, + PermissionMode: PermissionMode.WorkspaceWrite, + OutputFormat: OutputFormat.Text, + PrimaryMode: PrimaryMode.Build, + SessionId: null, + AgentId: null, + IsInteractive: false), + stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception exception) + { + logger.LogWarning(exception, "Scheduled prompt polling failed for workspace {WorkspaceRoot}.", workspaceRoot); + } + } + while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // Graceful shutdown. + } + finally + { + timer.Dispose(); + } + } +} diff --git a/src/SharpClaw.Code.Runtime/Workflow/ScheduledPromptService.cs b/src/SharpClaw.Code.Runtime/Workflow/ScheduledPromptService.cs new file mode 100644 index 0000000..1e39a11 --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Workflow/ScheduledPromptService.cs @@ -0,0 +1,227 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Protocol.Abstractions; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Runtime.Abstractions; +using SharpClaw.Code.Sessions.Abstractions; + +namespace SharpClaw.Code.Runtime.Workflow; + +/// +public sealed class ScheduledPromptService( + IScheduledPromptStore scheduledPromptStore, + IRuntimeCommandService runtimeCommandService, + IConversationRuntime conversationRuntime, + ISessionCoordinator sessionCoordinator, + ISystemClock systemClock, + IPathService pathService, + ILogger logger) : IScheduledPromptService +{ + private static readonly ConcurrentDictionary InFlightSchedules = new(StringComparer.Ordinal); + + /// + public Task> ListAsync(string workspaceRoot, CancellationToken cancellationToken) + => scheduledPromptStore.ListAsync(pathService.GetFullPath(workspaceRoot), cancellationToken); + + /// + public Task GetAsync(string workspaceRoot, string scheduleId, CancellationToken cancellationToken) + => scheduledPromptStore.GetByIdAsync(pathService.GetFullPath(workspaceRoot), scheduleId, cancellationToken); + + /// + public async Task SaveAsync(string workspaceRoot, ScheduledPromptDefinition definition, CancellationToken cancellationToken) + { + var normalizedWorkspace = pathService.GetFullPath(workspaceRoot); + var normalized = Normalize(definition with { WorkspaceRoot = normalizedWorkspace }, systemClock.UtcNow); + await scheduledPromptStore.SaveAsync(normalizedWorkspace, normalized, cancellationToken).ConfigureAwait(false); + return normalized; + } + + /// + public Task RemoveAsync(string workspaceRoot, string scheduleId, CancellationToken cancellationToken) + => scheduledPromptStore.DeleteAsync(pathService.GetFullPath(workspaceRoot), scheduleId, cancellationToken); + + /// + public async Task SetEnabledAsync( + string workspaceRoot, + string scheduleId, + bool enabled, + CancellationToken cancellationToken) + { + var normalizedWorkspace = pathService.GetFullPath(workspaceRoot); + var schedule = await scheduledPromptStore.GetByIdAsync(normalizedWorkspace, scheduleId, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Scheduled prompt '{scheduleId}' was not found."); + var updated = Normalize(schedule with { Enabled = enabled }, systemClock.UtcNow); + await scheduledPromptStore.SaveAsync(normalizedWorkspace, updated, cancellationToken).ConfigureAwait(false); + return updated; + } + + /// + public async Task RunAsync( + string workspaceRoot, + string scheduleId, + RuntimeCommandContext context, + CancellationToken cancellationToken) + { + var normalizedWorkspace = pathService.GetFullPath(workspaceRoot); + var schedule = await scheduledPromptStore.GetByIdAsync(normalizedWorkspace, scheduleId, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Scheduled prompt '{scheduleId}' was not found."); + return await RunScheduleAsync(normalizedWorkspace, schedule, context, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task> RunDueAsync( + string workspaceRoot, + RuntimeCommandContext context, + CancellationToken cancellationToken) + { + var normalizedWorkspace = pathService.GetFullPath(workspaceRoot); + var schedules = await scheduledPromptStore.ListAsync(normalizedWorkspace, cancellationToken).ConfigureAwait(false); + var now = systemClock.UtcNow; + var due = schedules + .Where(schedule => schedule.Enabled && schedule.NextRunUtc is { } nextRun && nextRun <= now) + .OrderBy(static schedule => schedule.NextRunUtc) + .ToArray(); + + var reports = new List(due.Length); + foreach (var schedule in due) + { + reports.Add(await RunScheduleAsync(normalizedWorkspace, schedule, context, cancellationToken).ConfigureAwait(false)); + } + + return reports; + } + + private async Task RunScheduleAsync( + string workspaceRoot, + ScheduledPromptDefinition schedule, + RuntimeCommandContext context, + CancellationToken cancellationToken) + { + var inflightKey = $"{workspaceRoot}::{schedule.Id}"; + if (!InFlightSchedules.TryAdd(inflightKey, 0)) + { + return new ScheduledPromptRunReport( + schedule.Id, + schedule.Name, + false, + "The scheduled prompt is already running in this process.", + systemClock.UtcNow, + systemClock.UtcNow); + } + + var startedAtUtc = systemClock.UtcNow; + try + { + var sessionId = await ResolveSessionIdAsync(workspaceRoot, schedule, cancellationToken).ConfigureAwait(false); + var runContext = new RuntimeCommandContext( + WorkingDirectory: workspaceRoot, + Model: schedule.ModelOverride ?? context.Model, + PermissionMode: schedule.PermissionMode, + OutputFormat: OutputFormat.Text, + PrimaryMode: schedule.PrimaryMode, + SessionId: sessionId, + AgentId: context.AgentId, + IsInteractive: false, + HostContext: context.HostContext, + ApprovalSettings: schedule.ApprovalSettings); + + var result = await runtimeCommandService + .ExecutePromptAsync(schedule.Prompt, runContext, cancellationToken) + .ConfigureAwait(false); + var completedAtUtc = systemClock.UtcNow; + var message = string.IsNullOrWhiteSpace(result.FinalOutput) + ? $"Completed scheduled prompt '{schedule.Name}'." + : result.FinalOutput!; + + var updated = Normalize( + schedule with + { + LastRunUtc = completedAtUtc, + LastOutcome = new ScheduledPromptLastOutcome(true, Truncate(message), completedAtUtc, result.Session.Id), + }, + completedAtUtc); + await scheduledPromptStore.SaveAsync(workspaceRoot, updated, cancellationToken).ConfigureAwait(false); + + return new ScheduledPromptRunReport( + schedule.Id, + schedule.Name, + true, + Truncate(message), + startedAtUtc, + completedAtUtc, + result.Session.Id); + } + catch (Exception exception) + { + logger.LogWarning(exception, "Scheduled prompt {ScheduleId} failed.", schedule.Id); + var completedAtUtc = systemClock.UtcNow; + var updated = Normalize( + schedule with + { + LastRunUtc = completedAtUtc, + LastOutcome = new ScheduledPromptLastOutcome(false, Truncate(exception.Message), completedAtUtc), + }, + completedAtUtc); + await scheduledPromptStore.SaveAsync(workspaceRoot, updated, cancellationToken).ConfigureAwait(false); + + return new ScheduledPromptRunReport( + schedule.Id, + schedule.Name, + false, + Truncate(exception.Message), + startedAtUtc, + completedAtUtc); + } + finally + { + InFlightSchedules.TryRemove(inflightKey, out _); + } + } + + private async Task ResolveSessionIdAsync(string workspaceRoot, ScheduledPromptDefinition schedule, CancellationToken cancellationToken) + { + return schedule.SessionTarget.Kind switch + { + ScheduledPromptSessionTargetKind.New => (await conversationRuntime + .CreateSessionAsync(workspaceRoot, schedule.PermissionMode, OutputFormat.Text, cancellationToken) + .ConfigureAwait(false)).Id, + ScheduledPromptSessionTargetKind.Attached => await sessionCoordinator + .GetAttachedSessionIdAsync(workspaceRoot, cancellationToken) + .ConfigureAwait(false) + ?? throw new InvalidOperationException("The schedule targets the attached session, but no session is attached for this workspace."), + ScheduledPromptSessionTargetKind.Explicit => string.IsNullOrWhiteSpace(schedule.SessionTarget.SessionId) + ? throw new InvalidOperationException("The schedule targets an explicit session, but no session id was configured.") + : schedule.SessionTarget.SessionId, + _ => throw new InvalidOperationException($"Unsupported schedule session target '{schedule.SessionTarget.Kind}'."), + }; + } + + private static ScheduledPromptDefinition Normalize(ScheduledPromptDefinition definition, DateTimeOffset now) + { + var nextRunUtc = definition.Enabled + ? ScheduleCronExpression.GetNextOccurrence(definition.Cron, now) + : (DateTimeOffset?)null; + + return definition with + { + Name = definition.Name.Trim(), + Prompt = definition.Prompt.Trim(), + Cron = definition.Cron.Trim(), + NextRunUtc = nextRunUtc, + }; + } + + private static string Truncate(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return "No output."; + } + + var trimmed = value.Trim(); + return trimmed.Length <= 240 ? trimmed : trimmed[..240]; + } +} diff --git a/src/SharpClaw.Code.Runtime/Workflow/SessionPreferenceService.cs b/src/SharpClaw.Code.Runtime/Workflow/SessionPreferenceService.cs new file mode 100644 index 0000000..7b4b5c6 --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Workflow/SessionPreferenceService.cs @@ -0,0 +1,314 @@ +using System.Text.Json; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Runtime.Abstractions; +using SharpClaw.Code.Sessions.Abstractions; + +namespace SharpClaw.Code.Runtime.Workflow; + +/// +public sealed class SessionPreferenceService( + ISessionStore sessionStore, + ISessionCoordinator sessionCoordinator) : ISessionPreferenceService +{ + /// + public async Task GetPermissionStatusAsync( + string workspaceRoot, + string? sessionId, + PermissionMode fallbackPermissionMode, + ApprovalSettings? approvalSettings, + string? currentModel, + CancellationToken cancellationToken) + { + var attachedSessionId = await sessionCoordinator.GetAttachedSessionIdAsync(workspaceRoot, cancellationToken).ConfigureAwait(false); + var session = await ResolveSessionAsync(workspaceRoot, sessionId, cancellationToken).ConfigureAwait(false); + return BuildReport(session, attachedSessionId, fallbackPermissionMode, approvalSettings, currentModel); + } + + /// + public async Task GrantTrustAsync( + string workspaceRoot, + string? sessionId, + TrustedSourceKind kind, + string name, + PermissionMode fallbackPermissionMode, + ApprovalSettings? approvalSettings, + string? currentModel, + CancellationToken cancellationToken) + { + var session = await RequireSessionAsync(workspaceRoot, sessionId, cancellationToken).ConfigureAwait(false); + var metadata = session.Metadata is null + ? new Dictionary(StringComparer.Ordinal) + : new Dictionary(session.Metadata, StringComparer.Ordinal); + var key = kind == TrustedSourceKind.Plugin + ? SharpClawWorkflowMetadataKeys.TrustedPluginNamesJson + : SharpClawWorkflowMetadataKeys.TrustedMcpServerNamesJson; + var names = ReadStringArray(metadata, key).ToHashSet(StringComparer.OrdinalIgnoreCase); + names.Add(name.Trim()); + metadata[key] = JsonSerializer.Serialize(names.OrderBy(static item => item, StringComparer.OrdinalIgnoreCase).ToArray(), ProtocolJsonContext.Default.StringArray); + session = session with { Metadata = metadata, UpdatedAtUtc = DateTimeOffset.UtcNow }; + await sessionStore.SaveAsync(workspaceRoot, session, cancellationToken).ConfigureAwait(false); + var attachedSessionId = await sessionCoordinator.GetAttachedSessionIdAsync(workspaceRoot, cancellationToken).ConfigureAwait(false); + return BuildReport(session, attachedSessionId, fallbackPermissionMode, approvalSettings, currentModel); + } + + /// + public async Task RevokeTrustAsync( + string workspaceRoot, + string? sessionId, + TrustedSourceKind kind, + string name, + PermissionMode fallbackPermissionMode, + ApprovalSettings? approvalSettings, + string? currentModel, + CancellationToken cancellationToken) + { + var session = await RequireSessionAsync(workspaceRoot, sessionId, cancellationToken).ConfigureAwait(false); + var metadata = session.Metadata is null + ? new Dictionary(StringComparer.Ordinal) + : new Dictionary(session.Metadata, StringComparer.Ordinal); + var key = kind == TrustedSourceKind.Plugin + ? SharpClawWorkflowMetadataKeys.TrustedPluginNamesJson + : SharpClawWorkflowMetadataKeys.TrustedMcpServerNamesJson; + var names = ReadStringArray(metadata, key).ToHashSet(StringComparer.OrdinalIgnoreCase); + names.Remove(name.Trim()); + metadata[key] = JsonSerializer.Serialize(names.OrderBy(static item => item, StringComparer.OrdinalIgnoreCase).ToArray(), ProtocolJsonContext.Default.StringArray); + session = session with { Metadata = metadata, UpdatedAtUtc = DateTimeOffset.UtcNow }; + await sessionStore.SaveAsync(workspaceRoot, session, cancellationToken).ConfigureAwait(false); + var attachedSessionId = await sessionCoordinator.GetAttachedSessionIdAsync(workspaceRoot, cancellationToken).ConfigureAwait(false); + return BuildReport(session, attachedSessionId, fallbackPermissionMode, approvalSettings, currentModel); + } + + /// + public async Task SetModelPreferenceAsync( + string workspaceRoot, + string? sessionId, + string model, + CancellationToken cancellationToken) + { + var session = await RequireSessionAsync(workspaceRoot, sessionId, cancellationToken).ConfigureAwait(false); + var metadata = session.Metadata is null + ? new Dictionary(StringComparer.Ordinal) + : new Dictionary(session.Metadata, StringComparer.Ordinal); + var preference = new SessionModelPreference(model.Trim(), DateTimeOffset.UtcNow); + metadata[SharpClawWorkflowMetadataKeys.SessionModelPreferenceJson] = JsonSerializer.Serialize(preference, ProtocolJsonContext.Default.SessionModelPreference); + session = session with { Metadata = metadata, UpdatedAtUtc = preference.UpdatedAtUtc }; + await sessionStore.SaveAsync(workspaceRoot, session, cancellationToken).ConfigureAwait(false); + return preference; + } + + /// + public async Task ClearModelPreferenceAsync(string workspaceRoot, string? sessionId, CancellationToken cancellationToken) + { + var session = await RequireSessionAsync(workspaceRoot, sessionId, cancellationToken).ConfigureAwait(false); + if (session.Metadata is null) + { + return false; + } + + var metadata = new Dictionary(session.Metadata, StringComparer.Ordinal); + var removed = metadata.Remove(SharpClawWorkflowMetadataKeys.SessionModelPreferenceJson); + if (!removed) + { + return false; + } + + session = session with { Metadata = metadata, UpdatedAtUtc = DateTimeOffset.UtcNow }; + await sessionStore.SaveAsync(workspaceRoot, session, cancellationToken).ConfigureAwait(false); + return true; + } + + /// + public async Task SetPreferredPermissionModeAsync( + string workspaceRoot, + string? sessionId, + PermissionMode permissionMode, + CancellationToken cancellationToken) + { + var session = await RequireSessionAsync(workspaceRoot, sessionId, cancellationToken).ConfigureAwait(false); + var metadata = session.Metadata is null + ? new Dictionary(StringComparer.Ordinal) + : new Dictionary(session.Metadata, StringComparer.Ordinal); + metadata[SharpClawWorkflowMetadataKeys.PreferredPermissionMode] = permissionMode.ToString(); + session = session with { Metadata = metadata, UpdatedAtUtc = DateTimeOffset.UtcNow }; + await sessionStore.SaveAsync(workspaceRoot, session, cancellationToken).ConfigureAwait(false); + return permissionMode; + } + + /// + public async Task SetApprovalSettingsAsync( + string workspaceRoot, + string? sessionId, + ApprovalSettings approvalSettings, + CancellationToken cancellationToken) + { + var session = await RequireSessionAsync(workspaceRoot, sessionId, cancellationToken).ConfigureAwait(false); + var metadata = session.Metadata is null + ? new Dictionary(StringComparer.Ordinal) + : new Dictionary(session.Metadata, StringComparer.Ordinal); + var normalized = ApprovalSettingsResolver.Normalize(approvalSettings); + + if (normalized.AutoApproveScopes.Count == 0) + { + metadata.Remove(SharpClawWorkflowMetadataKeys.ApprovalAutoApproveScopesJson); + } + else + { + metadata[SharpClawWorkflowMetadataKeys.ApprovalAutoApproveScopesJson] = JsonSerializer.Serialize( + normalized.AutoApproveScopes.ToList(), + ProtocolJsonContext.Default.ListApprovalScope); + } + + if (normalized.AutoApproveBudget is null) + { + metadata.Remove(SharpClawWorkflowMetadataKeys.ApprovalAutoApproveBudget); + } + else + { + metadata[SharpClawWorkflowMetadataKeys.ApprovalAutoApproveBudget] = normalized.AutoApproveBudget.Value.ToString(); + } + + session = session with { Metadata = metadata, UpdatedAtUtc = DateTimeOffset.UtcNow }; + await sessionStore.SaveAsync(workspaceRoot, session, cancellationToken).ConfigureAwait(false); + return normalized; + } + + /// + public async Task ClearApprovalSettingsAsync(string workspaceRoot, string? sessionId, CancellationToken cancellationToken) + { + var session = await RequireSessionAsync(workspaceRoot, sessionId, cancellationToken).ConfigureAwait(false); + if (session.Metadata is null) + { + return false; + } + + var metadata = new Dictionary(session.Metadata, StringComparer.Ordinal); + var removedScopes = metadata.Remove(SharpClawWorkflowMetadataKeys.ApprovalAutoApproveScopesJson); + var removedBudget = metadata.Remove(SharpClawWorkflowMetadataKeys.ApprovalAutoApproveBudget); + if (!removedScopes && !removedBudget) + { + return false; + } + + session = session with { Metadata = metadata, UpdatedAtUtc = DateTimeOffset.UtcNow }; + await sessionStore.SaveAsync(workspaceRoot, session, cancellationToken).ConfigureAwait(false); + return true; + } + + private async Task RequireSessionAsync(string workspaceRoot, string? sessionId, CancellationToken cancellationToken) + => await ResolveSessionAsync(workspaceRoot, sessionId, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("No session resolved. Start or attach a session first."); + + private async Task ResolveSessionAsync(string workspaceRoot, string? sessionId, CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(sessionId)) + { + return await sessionStore.GetByIdAsync(workspaceRoot, sessionId, cancellationToken).ConfigureAwait(false); + } + + var attached = await sessionCoordinator.GetAttachedSessionIdAsync(workspaceRoot, cancellationToken).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(attached)) + { + return await sessionStore.GetByIdAsync(workspaceRoot, attached, cancellationToken).ConfigureAwait(false); + } + + return await sessionStore.GetLatestAsync(workspaceRoot, cancellationToken).ConfigureAwait(false); + } + + private static PermissionStatusReport BuildReport( + ConversationSession? session, + string? attachedSessionId, + PermissionMode fallbackPermissionMode, + ApprovalSettings? approvalSettings, + string? currentModel) + { + var permissionMode = fallbackPermissionMode; + var effectiveApprovalSettings = approvalSettings; + if (session?.Metadata?.TryGetValue(SharpClawWorkflowMetadataKeys.PreferredPermissionMode, out var storedMode) == true + && Enum.TryParse(storedMode, ignoreCase: true, out var parsed)) + { + permissionMode = parsed; + } + + if (session?.Metadata is not null) + { + var scopes = ReadApprovalScopes(session.Metadata); + var budget = ReadApprovalBudget(session.Metadata); + if (scopes is not null || budget is not null) + { + effectiveApprovalSettings = ApprovalSettingsResolver.Normalize(new ApprovalSettings(scopes ?? [], budget)); + } + } + + var trustedSources = new List(); + foreach (var name in ReadStringArray(session?.Metadata, SharpClawWorkflowMetadataKeys.TrustedPluginNamesJson)) + { + trustedSources.Add(new TrustedSourceEntry(TrustedSourceKind.Plugin, name, session?.UpdatedAtUtc ?? DateTimeOffset.UtcNow)); + } + + foreach (var name in ReadStringArray(session?.Metadata, SharpClawWorkflowMetadataKeys.TrustedMcpServerNamesJson)) + { + trustedSources.Add(new TrustedSourceEntry(TrustedSourceKind.Mcp, name, session?.UpdatedAtUtc ?? DateTimeOffset.UtcNow)); + } + + var effectiveModel = currentModel; + if (session?.Metadata?.TryGetValue(SharpClawWorkflowMetadataKeys.SessionModelPreferenceJson, out var payload) == true) + { + try + { + effectiveModel = JsonSerializer.Deserialize(payload, ProtocolJsonContext.Default.SessionModelPreference)?.Model ?? currentModel; + } + catch (JsonException) + { + // Ignore malformed preference payload. + } + } + + return new PermissionStatusReport(permissionMode, effectiveApprovalSettings, trustedSources.ToArray(), attachedSessionId, effectiveModel); + } + + private static IReadOnlyList? ReadApprovalScopes(IReadOnlyDictionary metadata) + { + if (!metadata.TryGetValue(SharpClawWorkflowMetadataKeys.ApprovalAutoApproveScopesJson, out var payload) + || string.IsNullOrWhiteSpace(payload)) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(payload, ProtocolJsonContext.Default.ListApprovalScope); + } + catch (JsonException) + { + return null; + } + } + + private static int? ReadApprovalBudget(IReadOnlyDictionary metadata) + => metadata.TryGetValue(SharpClawWorkflowMetadataKeys.ApprovalAutoApproveBudget, out var payload) + && int.TryParse(payload, out var parsed) + && parsed > 0 + ? parsed + : null; + + private static IReadOnlyList ReadStringArray(IReadOnlyDictionary? metadata, string key) + { + if (metadata is null + || !metadata.TryGetValue(key, out var payload) + || string.IsNullOrWhiteSpace(payload)) + { + return []; + } + + try + { + return JsonSerializer.Deserialize(payload, ProtocolJsonContext.Default.StringArray) ?? []; + } + catch (JsonException) + { + return []; + } + } +} diff --git a/src/SharpClaw.Code.Runtime/Workflow/WorkspaceBootstrapService.cs b/src/SharpClaw.Code.Runtime/Workflow/WorkspaceBootstrapService.cs new file mode 100644 index 0000000..8906a96 --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Workflow/WorkspaceBootstrapService.cs @@ -0,0 +1,76 @@ +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Runtime.Abstractions; + +namespace SharpClaw.Code.Runtime.Workflow; + +/// +public sealed class WorkspaceBootstrapService( + IFileSystem fileSystem, + IPathService pathService) : IWorkspaceBootstrapService +{ + private const string DefaultConfig = """ +{ + // Workspace-local SharpClaw configuration. + "shareMode": "Manual", + "server": { + "host": "127.0.0.1", + "port": 7345 + } +} +"""; + + /// + public async Task InitializeAsync( + string workspaceRoot, + bool force, + bool includeCommandsDirectory, + bool includeSkillsDirectory, + CancellationToken cancellationToken) + { + var normalized = pathService.GetFullPath(workspaceRoot); + var sharpClawRoot = pathService.Combine(normalized, ".sharpclaw"); + var configPath = pathService.Combine(sharpClawRoot, "config.jsonc"); + var createdDirectories = new List(); + var configCreated = force || !fileSystem.FileExists(configPath); + + var hadSharpClawRoot = fileSystem.DirectoryExists(sharpClawRoot); + fileSystem.CreateDirectory(sharpClawRoot); + if (!hadSharpClawRoot) + { + createdDirectories.Add(sharpClawRoot); + } + + if (configCreated) + { + await fileSystem.WriteAllTextAsync(configPath, DefaultConfig, cancellationToken).ConfigureAwait(false); + } + + if (includeCommandsDirectory) + { + var commandsPath = pathService.Combine(sharpClawRoot, "commands"); + var existed = fileSystem.DirectoryExists(commandsPath); + fileSystem.CreateDirectory(commandsPath); + if (!existed) + { + createdDirectories.Add(commandsPath); + } + } + + if (includeSkillsDirectory) + { + var skillsPath = pathService.Combine(sharpClawRoot, "skills"); + var existed = fileSystem.DirectoryExists(skillsPath); + fileSystem.CreateDirectory(skillsPath); + if (!existed) + { + createdDirectories.Add(skillsPath); + } + } + + return new WorkspaceBootstrapResult( + normalized, + configPath, + configCreated, + createdDirectories.ToArray()); + } +} diff --git a/src/SharpClaw.Code.Sessions/Abstractions/IEvolutionProposalStore.cs b/src/SharpClaw.Code.Sessions/Abstractions/IEvolutionProposalStore.cs new file mode 100644 index 0000000..357f90e --- /dev/null +++ b/src/SharpClaw.Code.Sessions/Abstractions/IEvolutionProposalStore.cs @@ -0,0 +1,29 @@ +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Sessions.Abstractions; + +/// +/// Persists durable guided self-evolution proposals for one workspace. +/// +public interface IEvolutionProposalStore +{ + /// + /// Lists all evolution proposals for a workspace. + /// + Task> ListAsync(string workspacePath, CancellationToken cancellationToken); + + /// + /// Gets one evolution proposal by id. + /// + Task GetByIdAsync(string workspacePath, string proposalId, CancellationToken cancellationToken); + + /// + /// Saves one evolution proposal. + /// + Task SaveAsync(string workspacePath, EvolutionProposal proposal, CancellationToken cancellationToken); + + /// + /// Deletes one evolution proposal. + /// + Task DeleteAsync(string workspacePath, string proposalId, CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.Sessions/Abstractions/IScheduledPromptStore.cs b/src/SharpClaw.Code.Sessions/Abstractions/IScheduledPromptStore.cs new file mode 100644 index 0000000..609d55f --- /dev/null +++ b/src/SharpClaw.Code.Sessions/Abstractions/IScheduledPromptStore.cs @@ -0,0 +1,29 @@ +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Sessions.Abstractions; + +/// +/// Persists durable scheduled prompt definitions for one workspace. +/// +public interface IScheduledPromptStore +{ + /// + /// Lists all scheduled prompts for a workspace. + /// + Task> ListAsync(string workspacePath, CancellationToken cancellationToken); + + /// + /// Gets one scheduled prompt by id. + /// + Task GetByIdAsync(string workspacePath, string scheduleId, CancellationToken cancellationToken); + + /// + /// Saves one scheduled prompt definition. + /// + Task SaveAsync(string workspacePath, ScheduledPromptDefinition definition, CancellationToken cancellationToken); + + /// + /// Deletes one scheduled prompt definition. + /// + Task DeleteAsync(string workspacePath, string scheduleId, CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.Sessions/Storage/FileEvolutionProposalStore.cs b/src/SharpClaw.Code.Sessions/Storage/FileEvolutionProposalStore.cs new file mode 100644 index 0000000..30c45f0 --- /dev/null +++ b/src/SharpClaw.Code.Sessions/Storage/FileEvolutionProposalStore.cs @@ -0,0 +1,85 @@ +using System.Text.Json; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Sessions.Abstractions; + +namespace SharpClaw.Code.Sessions.Storage; + +/// +/// Stores evolution proposals as a workspace-local JSON catalog. +/// +public sealed class FileEvolutionProposalStore( + IFileSystem fileSystem, + IRuntimeStoragePathResolver storagePathResolver) : IEvolutionProposalStore +{ + /// + public async Task> ListAsync(string workspacePath, CancellationToken cancellationToken) + { + var path = storagePathResolver.GetEvolutionProposalsPath(workspacePath); + var items = await LoadAsync(path, cancellationToken).ConfigureAwait(false); + return items + .OrderByDescending(static item => item.UpdatedAtUtc ?? item.CreatedAtUtc) + .ToArray(); + } + + /// + public async Task GetByIdAsync(string workspacePath, string proposalId, CancellationToken cancellationToken) + => (await LoadAsync(storagePathResolver.GetEvolutionProposalsPath(workspacePath), cancellationToken).ConfigureAwait(false)) + .FirstOrDefault(item => string.Equals(item.Id, proposalId, StringComparison.Ordinal)); + + /// + public async Task SaveAsync(string workspacePath, EvolutionProposal proposal, CancellationToken cancellationToken) + { + var path = storagePathResolver.GetEvolutionProposalsPath(workspacePath); + var lockPath = storagePathResolver.GetEvolutionProposalsLockPath(workspacePath); + await using var gate = await fileSystem.AcquireExclusiveFileLockAsync(lockPath, cancellationToken).ConfigureAwait(false); + + var items = (await LoadAsync(path, cancellationToken).ConfigureAwait(false)).ToList(); + var index = items.FindIndex(item => string.Equals(item.Id, proposal.Id, StringComparison.Ordinal)); + if (index >= 0) + { + items[index] = proposal; + } + else + { + items.Add(proposal); + } + + await SaveAsync(path, items, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task DeleteAsync(string workspacePath, string proposalId, CancellationToken cancellationToken) + { + var path = storagePathResolver.GetEvolutionProposalsPath(workspacePath); + var lockPath = storagePathResolver.GetEvolutionProposalsLockPath(workspacePath); + await using var gate = await fileSystem.AcquireExclusiveFileLockAsync(lockPath, cancellationToken).ConfigureAwait(false); + + var items = (await LoadAsync(path, cancellationToken).ConfigureAwait(false)).ToList(); + var removed = items.RemoveAll(item => string.Equals(item.Id, proposalId, StringComparison.Ordinal)) > 0; + if (removed) + { + await SaveAsync(path, items, cancellationToken).ConfigureAwait(false); + } + + return removed; + } + + private async Task> LoadAsync(string path, CancellationToken cancellationToken) + { + var content = await fileSystem.ReadAllTextIfExistsAsync(path, cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(content)) + { + return []; + } + + return JsonSerializer.Deserialize(content, ProtocolJsonContext.Default.ListEvolutionProposal) ?? []; + } + + private Task SaveAsync(string path, IReadOnlyList items, CancellationToken cancellationToken) + => fileSystem.WriteAllTextAsync( + path, + JsonSerializer.Serialize(items, ProtocolJsonContext.Default.ListEvolutionProposal), + cancellationToken); +} diff --git a/src/SharpClaw.Code.Sessions/Storage/FileScheduledPromptStore.cs b/src/SharpClaw.Code.Sessions/Storage/FileScheduledPromptStore.cs new file mode 100644 index 0000000..6494358 --- /dev/null +++ b/src/SharpClaw.Code.Sessions/Storage/FileScheduledPromptStore.cs @@ -0,0 +1,86 @@ +using System.Text.Json; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Sessions.Abstractions; + +namespace SharpClaw.Code.Sessions.Storage; + +/// +/// Stores scheduled prompts as a workspace-local JSON catalog. +/// +public sealed class FileScheduledPromptStore( + IFileSystem fileSystem, + IRuntimeStoragePathResolver storagePathResolver) : IScheduledPromptStore +{ + /// + public async Task> ListAsync(string workspacePath, CancellationToken cancellationToken) + { + var path = storagePathResolver.GetScheduledPromptsPath(workspacePath); + var items = await LoadAsync(path, cancellationToken).ConfigureAwait(false); + return items + .OrderBy(static item => item.NextRunUtc ?? DateTimeOffset.MaxValue) + .ThenBy(static item => item.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + /// + public async Task GetByIdAsync(string workspacePath, string scheduleId, CancellationToken cancellationToken) + => (await LoadAsync(storagePathResolver.GetScheduledPromptsPath(workspacePath), cancellationToken).ConfigureAwait(false)) + .FirstOrDefault(item => string.Equals(item.Id, scheduleId, StringComparison.Ordinal)); + + /// + public async Task SaveAsync(string workspacePath, ScheduledPromptDefinition definition, CancellationToken cancellationToken) + { + var path = storagePathResolver.GetScheduledPromptsPath(workspacePath); + var lockPath = storagePathResolver.GetScheduledPromptsLockPath(workspacePath); + await using var gate = await fileSystem.AcquireExclusiveFileLockAsync(lockPath, cancellationToken).ConfigureAwait(false); + + var items = (await LoadAsync(path, cancellationToken).ConfigureAwait(false)).ToList(); + var index = items.FindIndex(item => string.Equals(item.Id, definition.Id, StringComparison.Ordinal)); + if (index >= 0) + { + items[index] = definition; + } + else + { + items.Add(definition); + } + + await SaveAsync(path, items, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task DeleteAsync(string workspacePath, string scheduleId, CancellationToken cancellationToken) + { + var path = storagePathResolver.GetScheduledPromptsPath(workspacePath); + var lockPath = storagePathResolver.GetScheduledPromptsLockPath(workspacePath); + await using var gate = await fileSystem.AcquireExclusiveFileLockAsync(lockPath, cancellationToken).ConfigureAwait(false); + + var items = (await LoadAsync(path, cancellationToken).ConfigureAwait(false)).ToList(); + var removed = items.RemoveAll(item => string.Equals(item.Id, scheduleId, StringComparison.Ordinal)) > 0; + if (removed) + { + await SaveAsync(path, items, cancellationToken).ConfigureAwait(false); + } + + return removed; + } + + private async Task> LoadAsync(string path, CancellationToken cancellationToken) + { + var content = await fileSystem.ReadAllTextIfExistsAsync(path, cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(content)) + { + return []; + } + + return JsonSerializer.Deserialize(content, ProtocolJsonContext.Default.ListScheduledPromptDefinition) ?? []; + } + + private Task SaveAsync(string path, IReadOnlyList items, CancellationToken cancellationToken) + => fileSystem.WriteAllTextAsync( + path, + JsonSerializer.Serialize(items, ProtocolJsonContext.Default.ListScheduledPromptDefinition), + cancellationToken); +} diff --git a/src/SharpClaw.Code.Sessions/Storage/HostAwareEvolutionProposalStore.cs b/src/SharpClaw.Code.Sessions/Storage/HostAwareEvolutionProposalStore.cs new file mode 100644 index 0000000..79c10ae --- /dev/null +++ b/src/SharpClaw.Code.Sessions/Storage/HostAwareEvolutionProposalStore.cs @@ -0,0 +1,35 @@ +using SharpClaw.Code.Protocol.Abstractions; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Sessions.Abstractions; + +namespace SharpClaw.Code.Sessions.Storage; + +/// +/// Selects the effective evolution-proposal backend from the active host context. +/// +public sealed class HostAwareEvolutionProposalStore( + FileEvolutionProposalStore fileStore, + SqliteEvolutionProposalStore sqliteStore, + IRuntimeHostContextAccessor hostContextAccessor) : IEvolutionProposalStore +{ + /// + public Task> ListAsync(string workspacePath, CancellationToken cancellationToken) + => ResolveStore().ListAsync(workspacePath, cancellationToken); + + /// + public Task GetByIdAsync(string workspacePath, string proposalId, CancellationToken cancellationToken) + => ResolveStore().GetByIdAsync(workspacePath, proposalId, cancellationToken); + + /// + public Task SaveAsync(string workspacePath, EvolutionProposal proposal, CancellationToken cancellationToken) + => ResolveStore().SaveAsync(workspacePath, proposal, cancellationToken); + + /// + public Task DeleteAsync(string workspacePath, string proposalId, CancellationToken cancellationToken) + => ResolveStore().DeleteAsync(workspacePath, proposalId, cancellationToken); + + private IEvolutionProposalStore ResolveStore() + => hostContextAccessor.Current?.SessionStoreKind == SessionStoreKind.Sqlite + ? sqliteStore + : fileStore; +} diff --git a/src/SharpClaw.Code.Sessions/Storage/HostAwareScheduledPromptStore.cs b/src/SharpClaw.Code.Sessions/Storage/HostAwareScheduledPromptStore.cs new file mode 100644 index 0000000..75f8ac3 --- /dev/null +++ b/src/SharpClaw.Code.Sessions/Storage/HostAwareScheduledPromptStore.cs @@ -0,0 +1,35 @@ +using SharpClaw.Code.Protocol.Abstractions; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Sessions.Abstractions; + +namespace SharpClaw.Code.Sessions.Storage; + +/// +/// Selects the effective scheduled-prompt backend from the active host context. +/// +public sealed class HostAwareScheduledPromptStore( + FileScheduledPromptStore fileStore, + SqliteScheduledPromptStore sqliteStore, + IRuntimeHostContextAccessor hostContextAccessor) : IScheduledPromptStore +{ + /// + public Task> ListAsync(string workspacePath, CancellationToken cancellationToken) + => ResolveStore().ListAsync(workspacePath, cancellationToken); + + /// + public Task GetByIdAsync(string workspacePath, string scheduleId, CancellationToken cancellationToken) + => ResolveStore().GetByIdAsync(workspacePath, scheduleId, cancellationToken); + + /// + public Task SaveAsync(string workspacePath, ScheduledPromptDefinition definition, CancellationToken cancellationToken) + => ResolveStore().SaveAsync(workspacePath, definition, cancellationToken); + + /// + public Task DeleteAsync(string workspacePath, string scheduleId, CancellationToken cancellationToken) + => ResolveStore().DeleteAsync(workspacePath, scheduleId, cancellationToken); + + private IScheduledPromptStore ResolveStore() + => hostContextAccessor.Current?.SessionStoreKind == SessionStoreKind.Sqlite + ? sqliteStore + : fileStore; +} diff --git a/src/SharpClaw.Code.Sessions/Storage/SqliteEvolutionProposalStore.cs b/src/SharpClaw.Code.Sessions/Storage/SqliteEvolutionProposalStore.cs new file mode 100644 index 0000000..5f8b8f4 --- /dev/null +++ b/src/SharpClaw.Code.Sessions/Storage/SqliteEvolutionProposalStore.cs @@ -0,0 +1,95 @@ +using System.Text.Json; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Sessions.Abstractions; + +namespace SharpClaw.Code.Sessions.Storage; + +/// +/// Stores evolution proposals in the workspace SQLite catalog. +/// +public sealed class SqliteEvolutionProposalStore( + IFileSystem fileSystem, + IRuntimeStoragePathResolver storagePathResolver) : IEvolutionProposalStore +{ + /// + public async Task> ListAsync(string workspacePath, CancellationToken cancellationToken) + { + await using var connection = await SqliteSessionStoreDatabase + .OpenConnectionAsync(fileSystem, storagePathResolver, workspacePath, cancellationToken) + .ConfigureAwait(false); + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT payload_json + FROM evolution_proposals + ORDER BY updated_at_utc DESC; + """; + + var items = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + if (!reader.IsDBNull(0)) + { + var item = JsonSerializer.Deserialize(reader.GetString(0), ProtocolJsonContext.Default.EvolutionProposal); + if (item is not null) + { + items.Add(item); + } + } + } + + return items; + } + + /// + public async Task GetByIdAsync(string workspacePath, string proposalId, CancellationToken cancellationToken) + { + await using var connection = await SqliteSessionStoreDatabase + .OpenConnectionAsync(fileSystem, storagePathResolver, workspacePath, cancellationToken) + .ConfigureAwait(false); + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT payload_json FROM evolution_proposals WHERE proposal_id = $proposalId LIMIT 1;"; + command.Parameters.AddWithValue("$proposalId", proposalId); + var payload = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false) as string; + return string.IsNullOrWhiteSpace(payload) + ? null + : JsonSerializer.Deserialize(payload, ProtocolJsonContext.Default.EvolutionProposal); + } + + /// + public async Task SaveAsync(string workspacePath, EvolutionProposal proposal, CancellationToken cancellationToken) + { + await using var connection = await SqliteSessionStoreDatabase + .OpenConnectionAsync(fileSystem, storagePathResolver, workspacePath, cancellationToken) + .ConfigureAwait(false); + await using var command = connection.CreateCommand(); + command.CommandText = """ + INSERT INTO evolution_proposals(proposal_id, updated_at_utc, status, payload_json) + VALUES ($proposalId, $updatedAtUtc, $status, $payloadJson) + ON CONFLICT(proposal_id) DO UPDATE SET + updated_at_utc = excluded.updated_at_utc, + status = excluded.status, + payload_json = excluded.payload_json; + """; + command.Parameters.AddWithValue("$proposalId", proposal.Id); + command.Parameters.AddWithValue("$updatedAtUtc", (proposal.UpdatedAtUtc ?? proposal.CreatedAtUtc).ToString("O")); + command.Parameters.AddWithValue("$status", proposal.Status.ToString()); + command.Parameters.AddWithValue("$payloadJson", JsonSerializer.Serialize(proposal, ProtocolJsonContext.Default.EvolutionProposal)); + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public async Task DeleteAsync(string workspacePath, string proposalId, CancellationToken cancellationToken) + { + await using var connection = await SqliteSessionStoreDatabase + .OpenConnectionAsync(fileSystem, storagePathResolver, workspacePath, cancellationToken) + .ConfigureAwait(false); + await using var command = connection.CreateCommand(); + command.CommandText = "DELETE FROM evolution_proposals WHERE proposal_id = $proposalId;"; + command.Parameters.AddWithValue("$proposalId", proposalId); + var affected = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + return affected > 0; + } +} diff --git a/src/SharpClaw.Code.Sessions/Storage/SqliteScheduledPromptStore.cs b/src/SharpClaw.Code.Sessions/Storage/SqliteScheduledPromptStore.cs new file mode 100644 index 0000000..959693e --- /dev/null +++ b/src/SharpClaw.Code.Sessions/Storage/SqliteScheduledPromptStore.cs @@ -0,0 +1,99 @@ +using System.Text.Json; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Sessions.Abstractions; + +namespace SharpClaw.Code.Sessions.Storage; + +/// +/// Stores scheduled prompts in the workspace SQLite catalog. +/// +public sealed class SqliteScheduledPromptStore( + IFileSystem fileSystem, + IRuntimeStoragePathResolver storagePathResolver) : IScheduledPromptStore +{ + /// + public async Task> ListAsync(string workspacePath, CancellationToken cancellationToken) + { + await using var connection = await SqliteSessionStoreDatabase + .OpenConnectionAsync(fileSystem, storagePathResolver, workspacePath, cancellationToken) + .ConfigureAwait(false); + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT payload_json + FROM scheduled_prompts + ORDER BY CASE WHEN next_run_utc IS NULL THEN 1 ELSE 0 END, + next_run_utc ASC, + updated_at_utc DESC; + """; + + var items = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + if (!reader.IsDBNull(0)) + { + var item = JsonSerializer.Deserialize(reader.GetString(0), ProtocolJsonContext.Default.ScheduledPromptDefinition); + if (item is not null) + { + items.Add(item); + } + } + } + + return items; + } + + /// + public async Task GetByIdAsync(string workspacePath, string scheduleId, CancellationToken cancellationToken) + { + await using var connection = await SqliteSessionStoreDatabase + .OpenConnectionAsync(fileSystem, storagePathResolver, workspacePath, cancellationToken) + .ConfigureAwait(false); + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT payload_json FROM scheduled_prompts WHERE schedule_id = $scheduleId LIMIT 1;"; + command.Parameters.AddWithValue("$scheduleId", scheduleId); + var payload = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false) as string; + return string.IsNullOrWhiteSpace(payload) + ? null + : JsonSerializer.Deserialize(payload, ProtocolJsonContext.Default.ScheduledPromptDefinition); + } + + /// + public async Task SaveAsync(string workspacePath, ScheduledPromptDefinition definition, CancellationToken cancellationToken) + { + await using var connection = await SqliteSessionStoreDatabase + .OpenConnectionAsync(fileSystem, storagePathResolver, workspacePath, cancellationToken) + .ConfigureAwait(false); + await using var command = connection.CreateCommand(); + command.CommandText = """ + INSERT INTO scheduled_prompts(schedule_id, updated_at_utc, enabled, next_run_utc, payload_json) + VALUES ($scheduleId, $updatedAtUtc, $enabled, $nextRunUtc, $payloadJson) + ON CONFLICT(schedule_id) DO UPDATE SET + updated_at_utc = excluded.updated_at_utc, + enabled = excluded.enabled, + next_run_utc = excluded.next_run_utc, + payload_json = excluded.payload_json; + """; + command.Parameters.AddWithValue("$scheduleId", definition.Id); + command.Parameters.AddWithValue("$updatedAtUtc", (definition.LastOutcome?.OccurredAtUtc ?? definition.LastRunUtc ?? DateTimeOffset.UtcNow).ToString("O")); + command.Parameters.AddWithValue("$enabled", definition.Enabled ? 1 : 0); + command.Parameters.AddWithValue("$nextRunUtc", definition.NextRunUtc?.ToString("O") ?? (object)DBNull.Value); + command.Parameters.AddWithValue("$payloadJson", JsonSerializer.Serialize(definition, ProtocolJsonContext.Default.ScheduledPromptDefinition)); + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public async Task DeleteAsync(string workspacePath, string scheduleId, CancellationToken cancellationToken) + { + await using var connection = await SqliteSessionStoreDatabase + .OpenConnectionAsync(fileSystem, storagePathResolver, workspacePath, cancellationToken) + .ConfigureAwait(false); + await using var command = connection.CreateCommand(); + command.CommandText = "DELETE FROM scheduled_prompts WHERE schedule_id = $scheduleId;"; + command.Parameters.AddWithValue("$scheduleId", scheduleId); + var affected = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + return affected > 0; + } +} diff --git a/src/SharpClaw.Code.Sessions/Storage/SqliteSessionStoreDatabase.cs b/src/SharpClaw.Code.Sessions/Storage/SqliteSessionStoreDatabase.cs index 86e4c2b..0960bce 100644 --- a/src/SharpClaw.Code.Sessions/Storage/SqliteSessionStoreDatabase.cs +++ b/src/SharpClaw.Code.Sessions/Storage/SqliteSessionStoreDatabase.cs @@ -46,6 +46,23 @@ CREATE TABLE IF NOT EXISTS runtime_events ( payload_json TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS ix_runtime_events_session_sequence ON runtime_events(session_id, sequence); + + CREATE TABLE IF NOT EXISTS scheduled_prompts ( + schedule_id TEXT PRIMARY KEY, + updated_at_utc TEXT NOT NULL, + enabled INTEGER NOT NULL, + next_run_utc TEXT NULL, + payload_json TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS ix_scheduled_prompts_enabled_next_run ON scheduled_prompts(enabled, next_run_utc); + + CREATE TABLE IF NOT EXISTS evolution_proposals ( + proposal_id TEXT PRIMARY KEY, + updated_at_utc TEXT NOT NULL, + status TEXT NOT NULL, + payload_json TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS ix_evolution_proposals_status_updated_at ON evolution_proposals(status, updated_at_utc DESC); """; await using var command = connection.CreateCommand();