diff --git a/cmd/gateway_http_handlers.go b/cmd/gateway_http_handlers.go index c270af1d..cf34003a 100644 --- a/cmd/gateway_http_handlers.go +++ b/cmd/gateway_http_handlers.go @@ -41,6 +41,7 @@ func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir if len(dirs) > 0 { skillsH = httpapi.NewSkillsHandler(manageStore, dirs[0], dataDir, bundledSkillsDir, msgBus, stores.SkillTenantCfgs, stores.Tenants) skillsH.SetDB(stores.DB) + skillsH.SetEvolutionStore(stores.SkillEvolution, stores.Activity) skillsH.SetUploadLimitConfig(skillUploadConfig) if stores.SystemConfigs != nil { skillsH.SetSystemConfigStore(stores.SystemConfigs) diff --git a/cmd/gateway_managed.go b/cmd/gateway_managed.go index 179ce8da..866381bd 100644 --- a/cmd/gateway_managed.go +++ b/cmd/gateway_managed.go @@ -216,7 +216,9 @@ func wireExtras( Tools: toolsReg, ToolPolicy: toolPE, Skills: skillsLoader, + SkillStore: stores.Skills, SkillAccessStore: skillAccessStore, + SkillEvolutionStore: stores.SkillEvolution, SkillSlashCommands: appCfg.Skills.SlashCommands, HasMemory: hasMemory, TraceCollector: traceCollector, diff --git a/cmd/skills_cmd.go b/cmd/skills_cmd.go index d87e1706..2ac79ace 100644 --- a/cmd/skills_cmd.go +++ b/cmd/skills_cmd.go @@ -22,6 +22,10 @@ func skillsCmd() *cobra.Command { } cmd.AddCommand(skillsListCmd()) cmd.AddCommand(skillsShowCmd()) + cmd.AddCommand(skillsEvolveCmd()) + cmd.AddCommand(skillsMetricsCmd()) + cmd.AddCommand(skillsActivityCmd()) + cmd.AddCommand(skillsSuggestionsCmd()) return cmd } diff --git a/cmd/skills_evolution_cmd.go b/cmd/skills_evolution_cmd.go new file mode 100644 index 00000000..5e74b55d --- /dev/null +++ b/cmd/skills_evolution_cmd.go @@ -0,0 +1,222 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "net/url" + "os" + "text/tabwriter" + + "github.com/spf13/cobra" +) + +func skillsEvolveCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "evolve", + Short: "Manage per-skill self-evolution settings", + } + cmd.AddCommand(skillsEvolveStatusCmd()) + cmd.AddCommand(skillsEvolveSetCmd("enable", true)) + cmd.AddCommand(skillsEvolveSetCmd("disable", false)) + cmd.AddCommand(skillsEvolveModeCmd()) + return cmd +} + +func skillsEvolveStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status [skill]", + Short: "Show self-evolution settings for a skill", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + requireRunningGatewayHTTP() + skillID := resolveGatewaySkillID(args[0]) + resp, err := gatewayHTTPGet("/v1/skills/" + url.PathEscape(skillID) + "/evolution") + exitOnErr(err) + printJSON(resp) + }, + } +} + +func skillsEvolveSetCmd(name string, enabled bool) *cobra.Command { + return &cobra.Command{ + Use: name + " [skill]", + Short: name + " self-evolution for a skill", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + requireRunningGatewayHTTP() + skillID := resolveGatewaySkillID(args[0]) + resp, err := gatewayHTTPDo("PATCH", "/v1/skills/"+url.PathEscape(skillID)+"/evolution", map[string]any{"enabled": enabled}) + exitOnErr(err) + printJSON(resp) + }, + } +} + +func skillsEvolveModeCmd() *cobra.Command { + return &cobra.Command{ + Use: "mode [skill] [suggest_only|auto_analyze]", + Short: "Set self-evolution mode for a skill", + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + requireRunningGatewayHTTP() + skillID := resolveGatewaySkillID(args[0]) + resp, err := gatewayHTTPDo("PATCH", "/v1/skills/"+url.PathEscape(skillID)+"/evolution", map[string]any{"mode": args[1]}) + exitOnErr(err) + printJSON(resp) + }, + } +} + +func skillsMetricsCmd() *cobra.Command { + var jsonOutput bool + cmd := &cobra.Command{ + Use: "metrics [skill]", + Short: "Show recorded usage metrics for a skill", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + requireRunningGatewayHTTP() + skillID := resolveGatewaySkillID(args[0]) + resp, err := gatewayHTTPGet("/v1/skills/" + url.PathEscape(skillID) + "/metrics") + exitOnErr(err) + if jsonOutput { + printJSON(resp) + return + } + fmt.Printf("Total: %.0f\nStarted: %.0f\nSucceeded: %.0f\nFailed: %.0f\nAbandoned: %.0f\nSuccess rate: %.2f\n", + num(resp["total_calls"]), num(resp["started"]), num(resp["succeeded"]), num(resp["failed"]), num(resp["abandoned"]), num(resp["success_rate"])) + }, + } + cmd.Flags().BoolVar(&jsonOutput, "json", false, "output as JSON") + return cmd +} + +func skillsActivityCmd() *cobra.Command { + return &cobra.Command{ + Use: "activity [skill]", + Short: "Show recent self-evolution activity for a skill", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + requireRunningGatewayHTTP() + skillID := resolveGatewaySkillID(args[0]) + resp, err := gatewayHTTPGet("/v1/skills/" + url.PathEscape(skillID) + "/activity") + exitOnErr(err) + printJSON(resp) + }, + } +} + +func skillsSuggestionsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "suggestions", + Short: "Manage skill improvement suggestions", + } + cmd.AddCommand(skillsSuggestionsListCmd()) + cmd.AddCommand(skillsSuggestionStatusCmd("approve")) + cmd.AddCommand(skillsSuggestionStatusCmd("reject")) + cmd.AddCommand(skillsSuggestionApplyCmd()) + return cmd +} + +func skillsSuggestionsListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list [skill]", + Short: "List suggestions for a skill", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + requireRunningGatewayHTTP() + skillID := resolveGatewaySkillID(args[0]) + resp, err := gatewayHTTPGet("/v1/skills/" + url.PathEscape(skillID) + "/evolution/suggestions") + exitOnErr(err) + printJSON(resp) + }, + } +} + +func skillsSuggestionStatusCmd(action string) *cobra.Command { + return &cobra.Command{ + Use: action + " [skill] [suggestion-id]", + Short: action + " a skill improvement suggestion", + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + requireRunningGatewayHTTP() + skillID := resolveGatewaySkillID(args[0]) + path := fmt.Sprintf("/v1/skills/%s/evolution/suggestions/%s/%s", url.PathEscape(skillID), url.PathEscape(args[1]), action) + resp, err := gatewayHTTPPost(path, nil) + exitOnErr(err) + printJSON(resp) + }, + } +} + +func skillsSuggestionApplyCmd() *cobra.Command { + var approve bool + cmd := &cobra.Command{ + Use: "apply [skill] [suggestion-id]", + Short: "Apply an approved skill improvement suggestion", + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + requireRunningGatewayHTTP() + skillID := resolveGatewaySkillID(args[0]) + path := fmt.Sprintf("/v1/skills/%s/evolution/suggestions/%s/apply", url.PathEscape(skillID), url.PathEscape(args[1])) + resp, err := gatewayHTTPPost(path, map[string]any{"approve": approve}) + exitOnErr(err) + printJSON(resp) + }, + } + cmd.Flags().BoolVar(&approve, "approve", false, "approve pending suggestion before applying") + return cmd +} + +func resolveGatewaySkillID(input string) string { + resp, err := gatewayHTTPGet("/v1/skills") + exitOnErr(err) + raw, _ := json.Marshal(resp["skills"]) + var skills []struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + } + _ = json.Unmarshal(raw, &skills) + for _, sk := range skills { + if sk.ID == input || sk.Slug == input || sk.Name == input { + return sk.ID + } + } + return input +} + +func printJSON(v any) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetIndent("", " ") + _ = enc.Encode(v) + fmt.Print(buf.String()) +} + +func exitOnErr(err error) { + if err != nil { + fmt.Fprintln(os.Stderr, "Error:", err) + os.Exit(1) + } +} + +func num(v any) float64 { + switch n := v.(type) { + case float64: + return n + case int: + return float64(n) + default: + return 0 + } +} + +func printSuggestionTable(items []map[string]any) { + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "ID\tSTATUS\tTYPE\tREASON") + for _, item := range items { + fmt.Fprintf(tw, "%v\t%v\t%v\t%v\n", item["id"], item["status"], item["suggestion_type"], item["reason"]) + } + tw.Flush() +} diff --git a/docs/18-http-api.md b/docs/18-http-api.md index f8c24449..2681959b 100644 --- a/docs/18-http-api.md +++ b/docs/18-http-api.md @@ -329,6 +329,36 @@ Use `direct_selection_count` plus the `selected_provider` sequence to verify rea | `PUT` | `/v1/skills/{id}/tenant-config` | Set tenant-level skill config | | `DELETE` | `/v1/skills/{id}/tenant-config` | Delete tenant-level skill config | +### Skill Self-Evolution + +Skill self-evolution is tenant-scoped and off by default per skill. Usage +metrics are written only by trusted runtime paths such as `use_skill` tool +execution and slash-command activation. There is no public usage-write endpoint. + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/v1/skills/{id}/evolution` | Read self-evolution settings for a skill | +| `PATCH` | `/v1/skills/{id}/evolution` | Update enabled state or mode | +| `GET` | `/v1/skills/{id}/metrics` | Read aggregate usage metrics and status counts | +| `GET` | `/v1/skills/{id}/activity` | Read admin-only skill evolution activity | +| `GET` | `/v1/skills/{id}/evolution/suggestions` | List skill-scoped improvement suggestions | +| `POST` | `/v1/skills/{id}/evolution/suggestions` | Create a suggestion with evidence and draft patch | +| `POST` | `/v1/skills/{id}/evolution/suggestions/{suggestionID}/approve` | Approve a suggestion | +| `POST` | `/v1/skills/{id}/evolution/suggestions/{suggestionID}/reject` | Reject a suggestion | +| `POST` | `/v1/skills/{id}/evolution/suggestions/{suggestionID}/apply` | Apply an approved suggestion to a new skill version | + +Supported modes: + +| Mode | Behavior | +|------|----------| +| `suggest_only` | Collect metrics and manage suggestions; no automatic patching | +| `auto_analyze` | Reserved for analysis automation; patch application still requires explicit approval | + +Viewer/operator callers can read aggregate metrics. Raw activity details, +actor IDs, failure evidence, draft patches, and suggestion apply actions remain +admin-controlled. System skill mutation is blocked; custom skill suggestions +write a new versioned directory and `skill_versions` record when applied. + ### Skill Grants Skill upload size is enforced per ZIP file. The effective limit resolves in this order: diff --git a/docs/21-agent-evolution-and-skill-management.md b/docs/21-agent-evolution-and-skill-management.md index b535df5c..42df78f3 100644 --- a/docs/21-agent-evolution-and-skill-management.md +++ b/docs/21-agent-evolution-and-skill-management.md @@ -354,7 +354,62 @@ All endpoints require authentication (`authMiddleware`). Mutation endpoints requ | `POST` | `/v1/skills/install-deps` | Install all missing deps | | `GET` | `/v1/skills/runtimes` | Check python3/node availability | -### 3.5 WebSocket RPC +### 3.5 Skill Self-Evolution + +Skill self-evolution tracks how each existing skill performs over time. It is +separate from agent-level `skill_evolve`, which teaches agents when to create or +patch reusable skills. + +**Runtime recording** + +- `use_skill` tool calls record tenant-scoped usage with status `succeeded` or + `failed`, duration, session key, run/trace ID, agent ID, and user scope. +- Slash-command activation records a `started` event when `/` or + `/use ` resolves to a skill. +- Usage writes are internal only. v1 intentionally has no public + `POST /v1/skills/{id}/usage` endpoint, so clients cannot forge success rates. + +**Persistent tables** + +| Table | Purpose | +|-------|---------| +| `skill_evolution_settings` | Per-tenant, per-skill enabled flag and mode | +| `skill_usage_metrics` | Runtime usage events and status counts | +| `skill_improvement_suggestions` | Skill-scoped suggestions with evidence and draft patches | +| `skill_versions` | Immutable applied-version records linked to changed files and suggestions | + +**HTTP and CLI controls** + +- HTTP: `GET/PATCH /v1/skills/{id}/evolution`, + `GET /v1/skills/{id}/metrics`, + `GET /v1/skills/{id}/activity`, and suggestion approve/reject/apply endpoints. +- CLI: `goclaw skills evolve`, `goclaw skills metrics`, + `goclaw skills suggestions`, and `goclaw skills activity`. +- Web UI: Skill detail has an `evolution` tab for settings, metrics, + suggestions, and admin-visible activity. + +**Guardrails** + +- Default mode is `suggest_only`; no automatic patching happens in v1. +- Applying a suggestion to a custom skill copies the current skill directory to + the next version, validates the target path, runs the SKILL.md guard scanner + when needed, updates the active skill, records `skill_versions`, and writes an + activity log entry. +- System/bundled skill mutation is refused by the apply path. +- Viewer surfaces are sanitized. Failure evidence, draft patches, actor IDs, + and activity details require admin visibility. + +**Relationship to self-improving skills** + +This v1 is the control-plane foundation for self-improving skills: runtime usage +events, evidence-backed suggestions, reference-file patches, version records, +and approval/audit surfaces. It does not yet run a consolidation extractor that +turns repeated corrections into learning notes or auto-applies user-scoped +reference overlays. That higher-level learning loop belongs above this +foundation and must keep scope separation, private-content filtering, evidence +thresholds, and owner/admin approval policies explicit. + +### 3.6 WebSocket RPC | Method | Description | |--------|-------------| @@ -362,7 +417,7 @@ All endpoints require authentication (`authMiddleware`). Mutation endpoints requ | `skills.get` | Get skill content by name | | `skills.update` | Update metadata (ownership-protected) | -### 3.6 Grants & Visibility +### 3.7 Grants & Visibility ```mermaid stateDiagram-v2 diff --git a/docs/project-changelog.md b/docs/project-changelog.md index 3b820c20..651fd82d 100644 --- a/docs/project-changelog.md +++ b/docs/project-changelog.md @@ -6,6 +6,33 @@ Significant changes, features, and fixes in reverse chronological order. ## 2026-06-12 +### Skill self-evolution metrics and upgrades (issue #161, issue #142 foundation) + +**Features** + +- Added per-skill self-evolution settings, usage metrics, suggestions, immutable + applied-version records, and activity logs. +- Added trusted runtime usage recording for `use_skill` and slash skill + activations without exposing a public usage-write endpoint. +- Added skill evolution HTTP, CLI, and Web UI surfaces for settings, metrics, + suggestions, approval/apply actions, and admin-visible activity. +- Added the shared foundation needed by self-improving skills: usage evidence, + reference-file patches, versioned apply, and approval/audit surfaces. The + consolidation learning extractor and auto user-scoped overlays remain out of + this v1 scope. + +**Safety** + +- Blocked direct system skill mutation in the suggestion apply path. +- Reused a shared target-path validator for skill companion/reference patches. +- Sanitized failure evidence, draft patches, actor IDs, and activity details + away from non-admin surfaces. + +**Tests** + +- Added focused skill path validator coverage and deep-link coverage for the new + Web UI evolution tab. + ### Mid-flight request preservation (issue #137) **Fixes** diff --git a/internal/agent/loop_history.go b/internal/agent/loop_history.go index ffaf232c..85a7a77d 100644 --- a/internal/agent/loop_history.go +++ b/internal/agent/loop_history.go @@ -117,7 +117,15 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s } } - userMessage, extraSystemPrompt, skillFilter = l.applySkillSlashCommand(ctx, userMessage, extraSystemPrompt, skillFilter) + slashReq := &RunRequest{ + SessionKey: sessionKey, + UserID: userID, + SenderID: store.SenderIDFromContext(ctx), + Channel: channel, + ChatID: chatID, + PeerKind: peerKind, + } + userMessage, extraSystemPrompt, skillFilter = l.applySkillSlashCommand(ctx, slashReq, userMessage, extraSystemPrompt, skillFilter) // Build tool list, filtering out skill_manage when skill_evolve is off. // Also applies ChannelAware filtering so channel-specific tools don't diff --git a/internal/agent/loop_pipeline_tool_callbacks.go b/internal/agent/loop_pipeline_tool_callbacks.go index 90373ab1..cb8bafed 100644 --- a/internal/agent/loop_pipeline_tool_callbacks.go +++ b/internal/agent/loop_pipeline_tool_callbacks.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "log/slog" + "strings" "time" "github.com/google/uuid" @@ -58,6 +59,9 @@ func (l *Loop) makeExecuteToolCall(req *RunRequest, bridgeRS *runState) func(ctx // v3 evolution metrics: record tool execution non-blocking (best-effort). l.recordToolMetric(ctx, req.SessionKey, registryName, !result.IsError, toolDuration) + if registryName == "use_skill" { + l.recordSkillUsageFromTool(ctx, req, tc, result, toolDuration) + } toolMsg, warningMsgs, action := l.processToolResult(ctx, bridgeRS, req, emitRun, tc, registryName, result, state.Context.HadBootstrap) syncBridgeToState(bridgeRS, state, action) @@ -168,6 +172,9 @@ func (l *Loop) makeProcessToolResult(req *RunRequest, bridgeRS *runState) func(c // Record tool metrics (non-blocking, best-effort). l.recordToolMetric(ctx, req.SessionKey, registryName, !result.IsError, dur) + if registryName == "use_skill" { + l.recordSkillUsageFromTool(ctx, req, tc, result, dur) + } toolMsg, warningMsgs, action := l.processToolResult(ctx, bridgeRS, req, emitRun, tc, registryName, result, state.Context.HadBootstrap) syncBridgeToState(bridgeRS, state, action) @@ -246,6 +253,68 @@ func (l *Loop) recordToolMetric(ctx context.Context, sessionKey, toolName string }() } +func (l *Loop) recordSkillUsageFromTool(ctx context.Context, req *RunRequest, tc providers.ToolCall, result *tools.Result, duration time.Duration) { + name, _ := tc.Arguments["name"].(string) + name = strings.TrimSpace(name) + if name == "" { + return + } + status := store.SkillUsageStatusSucceeded + reason := "" + if result != nil && result.IsError { + status = store.SkillUsageStatusFailed + reason = strings.TrimSpace(result.ForLLM) + if len(reason) > 500 { + reason = reason[:500] + } + } + l.recordSkillUsage(ctx, req, name, tc.ID, "use_skill", status, reason, duration) +} + +func (l *Loop) recordSkillUsage(ctx context.Context, req *RunRequest, skillName, invocationID, source, status, reason string, duration time.Duration) { + if l.skillEvolutionStore == nil || l.skillStore == nil { + return + } + info, ok := l.skillStore.GetSkill(ctx, skillName) + if !ok || info == nil || info.ID == "" { + return + } + skillID, err := uuid.Parse(info.ID) + if err != nil { + return + } + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + tenantID = l.tenantID + } + metric := store.SkillUsageMetric{ + ID: uuid.New(), + SkillID: skillID, + SkillSlug: info.Slug, + SkillVersion: info.Version, + AgentID: l.agentUUID, + InvocationID: invocationID, + InvocationSource: source, + Status: status, + FailureReason: reason, + DurationMs: duration.Milliseconds(), + } + if req != nil { + metric.UserID = req.UserID + metric.SessionKey = req.SessionKey + if req.RunID != "" { + metric.TraceID = req.RunID + } + } + go func() { + bgCtx, cancel := context.WithTimeout(store.WithTenantID(context.Background(), tenantID), 5*time.Second) + defer cancel() + if err := l.skillEvolutionStore.RecordUsage(bgCtx, metric); err != nil { + slog.Debug("skill.metric.record_failed", "skill", skillName, "source", source, "error", err) + } + }() +} + // makeToolEmitRun creates a tool event emitter with request context. func makeToolEmitRun(l *Loop, req *RunRequest) func(AgentEvent) { return func(event AgentEvent) { diff --git a/internal/agent/loop_types.go b/internal/agent/loop_types.go index 816e06a1..c1663959 100644 --- a/internal/agent/loop_types.go +++ b/internal/agent/loop_types.go @@ -254,6 +254,10 @@ type Loop struct { // v3 evolution metrics store (nil = disabled) evolutionMetricsStore store.EvolutionMetricsStore + // Skill self-evolution metrics store (nil = disabled) + skillEvolutionStore store.SkillEvolutionStore + skillStore store.SkillStore + // User identity resolver: maps channel contacts to merged tenant users for credential lookups. userResolver UserIdentityResolver @@ -454,6 +458,10 @@ type LoopConfig struct { // V3 evolution metrics store for recording tool/retrieval/feedback metrics EvolutionMetricsStore store.EvolutionMetricsStore + // Skill self-evolution metrics store for use_skill/slash activation metrics + SkillEvolutionStore store.SkillEvolutionStore + SkillStore store.SkillStore + // User identity resolver for credential lookups (maps channel contacts → tenant users) UserResolver UserIdentityResolver } @@ -586,6 +594,8 @@ func NewLoop(cfg LoopConfig) *Loop { orchMode: cfg.OrchMode, delegateTargets: cfg.DelegateTargets, evolutionMetricsStore: cfg.EvolutionMetricsStore, + skillEvolutionStore: cfg.SkillEvolutionStore, + skillStore: cfg.SkillStore, userResolver: cfg.UserResolver, } } diff --git a/internal/agent/resolver.go b/internal/agent/resolver.go index 823e17fc..174fe08a 100644 --- a/internal/agent/resolver.go +++ b/internal/agent/resolver.go @@ -85,8 +85,10 @@ type ResolverDeps struct { MCPGrantChecker mcpbridge.GrantChecker // Skill access store — for per-agent skill visibility filtering - SkillAccessStore store.SkillAccessStore - SkillSlashCommands config.SkillSlashCommandConfig + SkillAccessStore store.SkillAccessStore + SkillStore store.SkillStore + SkillEvolutionStore store.SkillEvolutionStore + SkillSlashCommands config.SkillSlashCommandConfig // Config permission store for group file writer checks ConfigPermStore store.ConfigPermissionStore @@ -542,6 +544,8 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { OrchMode: orchMode, DelegateTargets: delegateTargets, EvolutionMetricsStore: evoMetricsStore, + SkillEvolutionStore: deps.SkillEvolutionStore, + SkillStore: deps.SkillStore, UserResolver: newContactResolver(deps.ContactStore), }) diff --git a/internal/agent/skill_slash_commands.go b/internal/agent/skill_slash_commands.go index c9b1f34e..b4d60267 100644 --- a/internal/agent/skill_slash_commands.go +++ b/internal/agent/skill_slash_commands.go @@ -6,6 +6,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/skills" + "github.com/nextlevelbuilder/goclaw/internal/store" ) type skillSlashCommandKind int @@ -27,7 +28,7 @@ type skillSlashCommandResult struct { Suggestions []skills.Info } -func (l *Loop) applySkillSlashCommand(ctx context.Context, message, extraPrompt string, skillFilter []string) (string, string, []string) { +func (l *Loop) applySkillSlashCommand(ctx context.Context, req *RunRequest, message, extraPrompt string, skillFilter []string) (string, string, []string) { result := resolveSkillSlashCommand(ctx, l.skillsLoader, l.resolveSkillSlashCommandConfig(ctx), message) if result.Kind == skillSlashCommandNone { return message, extraPrompt, skillFilter @@ -41,6 +42,7 @@ func (l *Loop) applySkillSlashCommand(ctx context.Context, message, extraPrompt message = result.RemainingPrompt } skillFilter = []string{result.Skill.Slug} + l.recordSkillUsage(ctx, req, result.Skill.Slug, "", "slash", store.SkillUsageStatusStarted, "", 0) case skillSlashCommandList: message = "List the available skills shown in the system instructions." case skillSlashCommandHelp: diff --git a/internal/http/skills.go b/internal/http/skills.go index 7d04a4e5..dcd0ca58 100644 --- a/internal/http/skills.go +++ b/internal/http/skills.go @@ -36,12 +36,19 @@ type SkillsHandler struct { msgBus *bus.MessageBus tenantCfgStore store.SkillTenantConfigStore tenantStore store.TenantStore + evolutionStore store.SkillEvolutionStore + activityStore store.ActivityStore db *sql.DB // for export/import direct queries uploadLocks sync.Map // per-slug mutex; bounded by validated slug set, entries are tiny (*sync.Mutex) uploadLimitCfg config.SkillsConfig systemConfigs store.SystemConfigStore } +func (h *SkillsHandler) SetEvolutionStore(evolution store.SkillEvolutionStore, activity store.ActivityStore) { + h.evolutionStore = evolution + h.activityStore = activity +} + // NewSkillsHandler creates a handler for skill management endpoints. func NewSkillsHandler(skills store.SkillManageStore, baseDir, dataDir, bundledDir string, msgBus *bus.MessageBus, tenantCfgStore store.SkillTenantConfigStore, tenantStore store.TenantStore) *SkillsHandler { return &SkillsHandler{skills: skills, baseDir: baseDir, dataDir: dataDir, bundledDir: bundledDir, msgBus: msgBus, tenantCfgStore: tenantCfgStore, tenantStore: tenantStore, uploadLimitCfg: config.SkillsConfig{MaxUploadSizeMB: config.DefaultSkillMaxUploadSizeMB}} @@ -88,10 +95,19 @@ func (h *SkillsHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /v1/skills/{id}/versions", h.authMiddleware(h.handleListVersions)) mux.HandleFunc("GET /v1/skills/{id}/files/{path...}", h.authMiddleware(h.handleReadFile)) mux.HandleFunc("GET /v1/skills/{id}/files", h.authMiddleware(h.handleListFiles)) + mux.HandleFunc("GET /v1/skills/{id}/evolution", h.authMiddleware(h.handleGetEvolution)) + mux.HandleFunc("GET /v1/skills/{id}/metrics", h.authMiddleware(h.handleGetSkillMetrics)) + mux.HandleFunc("GET /v1/skills/{id}/activity", h.adminMiddleware(h.handleGetSkillActivity)) + mux.HandleFunc("GET /v1/skills/{id}/evolution/suggestions", h.authMiddleware(h.handleListSkillSuggestions)) // Skill writes (admin+) mux.HandleFunc("POST /v1/skills/upload", h.adminMiddleware(h.handleUpload)) mux.HandleFunc("PUT /v1/skills/{id}", h.adminMiddleware(h.handleUpdate)) mux.HandleFunc("DELETE /v1/skills/{id}", h.adminMiddleware(h.handleDelete)) + mux.HandleFunc("PATCH /v1/skills/{id}/evolution", h.adminMiddleware(h.handlePatchEvolution)) + mux.HandleFunc("POST /v1/skills/{id}/evolution/suggestions", h.adminMiddleware(h.handleCreateSkillSuggestion)) + mux.HandleFunc("POST /v1/skills/{id}/evolution/suggestions/{suggestionID}/approve", h.adminMiddleware(h.handleApproveSkillSuggestion)) + mux.HandleFunc("POST /v1/skills/{id}/evolution/suggestions/{suggestionID}/reject", h.adminMiddleware(h.handleRejectSkillSuggestion)) + mux.HandleFunc("POST /v1/skills/{id}/evolution/suggestions/{suggestionID}/apply", h.adminMiddleware(h.handleApplySkillSuggestion)) // Skill grants (admin+) mux.HandleFunc("GET /v1/skills/{id}/grants/agent", h.adminMiddleware(h.handleListAgentGrants)) mux.HandleFunc("POST /v1/skills/{id}/grants/agent", h.adminMiddleware(h.handleGrantAgent)) diff --git a/internal/http/skills_evolution.go b/internal/http/skills_evolution.go new file mode 100644 index 00000000..c650e44d --- /dev/null +++ b/internal/http/skills_evolution.go @@ -0,0 +1,542 @@ +package http + +import ( + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/permissions" + "github.com/nextlevelbuilder/goclaw/internal/skills" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +type skillEvolutionPatchRequest struct { + Enabled *bool `json:"enabled"` + Mode string `json:"mode"` +} + +type skillSuggestionCreateRequest struct { + SuggestionType string `json:"suggestion_type"` + Reason string `json:"reason"` + Evidence json.RawMessage `json:"evidence"` + DraftPatch json.RawMessage `json:"draft_patch"` + TargetFile string `json:"target_file"` +} + +type skillSuggestionApplyRequest struct { + Approve bool `json:"approve"` +} + +type skillDraftPatch struct { + Find string `json:"find"` + Replace string `json:"replace"` + Content *string `json:"content"` +} + +func (h *SkillsHandler) evolutionConfigured(w http.ResponseWriter) bool { + if h.evolutionStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "skill evolution store not configured"}) + return false + } + return true +} + +func (h *SkillsHandler) skillIDFromRequest(w http.ResponseWriter, r *http.Request) (uuid.UUID, store.SkillInfo, bool) { + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid skill id"}) + return uuid.Nil, store.SkillInfo{}, false + } + info, ok := h.skills.GetSkillByID(r.Context(), id) + if !ok { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "skill not found"}) + return uuid.Nil, store.SkillInfo{}, false + } + return id, info, true +} + +func (h *SkillsHandler) handleGetEvolution(w http.ResponseWriter, r *http.Request) { + if !h.evolutionConfigured(w) { + return + } + skillID, _, ok := h.skillIDFromRequest(w, r) + if !ok { + return + } + settings, err := h.evolutionStore.GetSettings(r.Context(), skillID) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, settings) +} + +func (h *SkillsHandler) handlePatchEvolution(w http.ResponseWriter, r *http.Request) { + if !h.evolutionConfigured(w) { + return + } + skillID, _, ok := h.skillIDFromRequest(w, r) + if !ok { + return + } + var req skillEvolutionPatchRequest + if !bindJSON(w, r, store.LocaleFromContext(r.Context()), &req) { + return + } + current, err := h.evolutionStore.GetSettings(r.Context(), skillID) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + enabled := current.Enabled + if req.Enabled != nil { + enabled = *req.Enabled + } + mode := strings.TrimSpace(req.Mode) + if mode == "" { + mode = current.Mode + } + if mode == "" { + mode = store.SkillEvolutionModeSuggestOnly + } + if mode != store.SkillEvolutionModeSuggestOnly && mode != store.SkillEvolutionModeAutoAnalyze { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid evolution mode"}) + return + } + updated, err := h.evolutionStore.UpsertSettings(r.Context(), store.SkillEvolutionSettings{ + SkillID: skillID, + Enabled: enabled, + Mode: mode, + }) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + action := "skill.evolve.mode_updated" + if req.Enabled != nil { + if enabled { + action = "skill.evolve.enabled" + } else { + action = "skill.evolve.disabled" + } + } + h.logSkillActivity(r, action, skillID, map[string]any{"enabled": enabled, "mode": mode}) + writeJSON(w, http.StatusOK, updated) +} + +func (h *SkillsHandler) handleGetSkillMetrics(w http.ResponseWriter, r *http.Request) { + if !h.evolutionConfigured(w) { + return + } + skillID, _, ok := h.skillIDFromRequest(w, r) + if !ok { + return + } + var since *time.Time + if raw := strings.TrimSpace(r.URL.Query().Get("since")); raw != "" { + if t, err := time.Parse(time.RFC3339, raw); err == nil { + since = &t + } + } + stats, err := h.evolutionStore.AggregateUsage(r.Context(), skillID, since) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if !permissions.HasMinRole(resolveAuth(r).Role, permissions.RoleAdmin) { + stats.TopFailureReasons = nil + } + writeJSON(w, http.StatusOK, stats) +} + +func (h *SkillsHandler) handleGetSkillActivity(w http.ResponseWriter, r *http.Request) { + if h.activityStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "activity store not configured"}) + return + } + skillID, _, ok := h.skillIDFromRequest(w, r) + if !ok { + return + } + items, err := h.activityStore.List(r.Context(), store.ActivityListOpts{ + EntityType: "skill", + EntityID: skillID.String(), + Limit: clampLimit(r.URL.Query().Get("limit"), 50, 200), + }) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"activity": items}) +} + +func (h *SkillsHandler) handleListSkillSuggestions(w http.ResponseWriter, r *http.Request) { + if !h.evolutionConfigured(w) { + return + } + skillID, _, ok := h.skillIDFromRequest(w, r) + if !ok { + return + } + items, err := h.evolutionStore.ListSuggestions(r.Context(), skillID, r.URL.Query().Get("status"), clampLimit(r.URL.Query().Get("limit"), 50, 200)) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if !permissions.HasMinRole(resolveAuth(r).Role, permissions.RoleAdmin) { + for i := range items { + items[i].Evidence = nil + items[i].DraftPatch = nil + items[i].CreatedByActorID = "" + items[i].ReviewedByActorID = "" + } + } + writeJSON(w, http.StatusOK, map[string]any{"suggestions": items}) +} + +func (h *SkillsHandler) handleCreateSkillSuggestion(w http.ResponseWriter, r *http.Request) { + if !h.evolutionConfigured(w) { + return + } + skillID, _, ok := h.skillIDFromRequest(w, r) + if !ok { + return + } + var req skillSuggestionCreateRequest + if !bindJSON(w, r, store.LocaleFromContext(r.Context()), &req) { + return + } + if strings.TrimSpace(req.SuggestionType) == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "suggestion_type is required"}) + return + } + target := strings.TrimSpace(req.TargetFile) + if target != "" { + clean, err := skills.ValidateSkillTargetPath(target, true) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + target = clean + } + created, err := h.evolutionStore.CreateSuggestion(r.Context(), store.SkillImprovementSuggestion{ + SkillID: skillID, + SuggestionType: req.SuggestionType, + Reason: req.Reason, + Evidence: req.Evidence, + DraftPatch: req.DraftPatch, + TargetFile: target, + CreatedByActorType: "user", + CreatedByActorID: store.ActorIDFromContext(r.Context()), + }) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + h.logSkillActivity(r, "skill.suggestion.created", skillID, map[string]any{"suggestion_id": created.ID.String(), "type": created.SuggestionType}) + writeJSON(w, http.StatusCreated, created) +} + +func (h *SkillsHandler) handleApproveSkillSuggestion(w http.ResponseWriter, r *http.Request) { + h.handleSuggestionStatus(w, r, store.SkillSuggestionStatusApproved, "skill.suggestion.approved") +} + +func (h *SkillsHandler) handleRejectSkillSuggestion(w http.ResponseWriter, r *http.Request) { + h.handleSuggestionStatus(w, r, store.SkillSuggestionStatusRejected, "skill.suggestion.rejected") +} + +func (h *SkillsHandler) handleSuggestionStatus(w http.ResponseWriter, r *http.Request, status, action string) { + if !h.evolutionConfigured(w) { + return + } + skillID, _, ok := h.skillIDFromRequest(w, r) + if !ok { + return + } + suggestionID, err := uuid.Parse(r.PathValue("suggestionID")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid suggestion id"}) + return + } + updated, err := h.evolutionStore.UpdateSuggestionStatus(r.Context(), suggestionID, status, "user", store.ActorIDFromContext(r.Context())) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if updated.SkillID != skillID { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "suggestion not found"}) + return + } + h.logSkillActivity(r, action, skillID, map[string]any{"suggestion_id": suggestionID.String()}) + writeJSON(w, http.StatusOK, updated) +} + +func (h *SkillsHandler) handleApplySkillSuggestion(w http.ResponseWriter, r *http.Request) { + if !h.evolutionConfigured(w) { + return + } + skillID, info, ok := h.skillIDFromRequest(w, r) + if !ok { + return + } + if info.IsSystem { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "system skill mutation is blocked"}) + return + } + suggestionID, err := uuid.Parse(r.PathValue("suggestionID")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid suggestion id"}) + return + } + var req skillSuggestionApplyRequest + _ = json.NewDecoder(r.Body).Decode(&req) + sg, err := h.evolutionStore.GetSuggestion(r.Context(), suggestionID) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if sg == nil || sg.SkillID != skillID { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "suggestion not found"}) + return + } + if sg.Status == store.SkillSuggestionStatusApplied && sg.AppliedVersion != nil { + writeJSON(w, http.StatusOK, sg) + return + } + if sg.Status != store.SkillSuggestionStatusApproved { + if !req.Approve { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "suggestion must be approved before apply"}) + return + } + sg, err = h.evolutionStore.UpdateSuggestionStatus(r.Context(), suggestionID, store.SkillSuggestionStatusApproved, "user", store.ActorIDFromContext(r.Context())) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + } + applied, err := h.applySkillSuggestionPatch(r, skillID, sg) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, applied) +} + +func (h *SkillsHandler) applySkillSuggestionPatch(r *http.Request, skillID uuid.UUID, sg *store.SkillImprovementSuggestion) (*store.SkillImprovementSuggestion, error) { + currentDir, slug, oldVersion, isSystem, ok := h.skills.GetSkillFilePath(r.Context(), skillID) + if !ok { + return nil, fmt.Errorf("skill not found") + } + if isSystem { + return nil, fmt.Errorf("system skill mutation is blocked") + } + target := sg.TargetFile + if strings.TrimSpace(target) == "" { + target = "SKILL.md" + } + cleanTarget, err := skills.ValidateSkillTargetPath(target, true) + if err != nil { + return nil, err + } + var patch skillDraftPatch + if len(sg.DraftPatch) > 0 { + if err := json.Unmarshal(sg.DraftPatch, &patch); err != nil { + return nil, fmt.Errorf("invalid draft_patch: %w", err) + } + } + if patch.Content == nil && patch.Find == "" { + return nil, fmt.Errorf("draft_patch requires content or find/replace") + } + newVersion, commitLock, err := h.skills.GetNextVersionLocked(r.Context(), slug) + if err != nil { + return nil, err + } + defer commitLock() //nolint:errcheck + + destDir := filepath.Join(h.tenantSkillsDir(r), slug, fmt.Sprintf("%d", newVersion)) + tmpDir := destDir + ".tmp-" + uuid.NewString() + if err := copyDir(currentDir, tmpDir); err != nil { + return nil, fmt.Errorf("stage version: %w", err) + } + cleanup := true + defer func() { + if cleanup { + _ = os.RemoveAll(tmpDir) + _ = os.RemoveAll(destDir) + } + }() + + targetPath := filepath.Join(tmpDir, filepath.FromSlash(cleanTarget)) + var nextContent string + if patch.Content != nil { + nextContent = *patch.Content + } else { + currentBytes, err := os.ReadFile(targetPath) + if err != nil { + return nil, fmt.Errorf("read target file: %w", err) + } + nextContent = string(currentBytes) + replaced := strings.Replace(nextContent, patch.Find, patch.Replace, 1) + if replaced == nextContent { + return nil, fmt.Errorf("find text not found in target file") + } + nextContent = replaced + } + if cleanTarget == "SKILL.md" { + violations, safe := skills.GuardSkillContent(nextContent) + if !safe { + return nil, errors.New(skills.FormatGuardViolations(violations)) + } + } + if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { + return nil, fmt.Errorf("create target directory: %w", err) + } + if err := os.WriteFile(targetPath, []byte(nextContent), 0644); err != nil { + return nil, fmt.Errorf("write target file: %w", err) + } + if err := os.Rename(tmpDir, destDir); err != nil { + return nil, fmt.Errorf("commit version files: %w", err) + } + + hash, size, err := hashSkillDir(destDir) + if err != nil { + return nil, err + } + if err := h.skills.UpdateSkill(r.Context(), skillID, map[string]any{ + "version": newVersion, + "file_path": destDir, + "file_size": size, + "file_hash": &hash, + "updated_at": time.Now(), + }); err != nil { + return nil, fmt.Errorf("update skill: %w", err) + } + changedFiles, _ := json.Marshal([]string{cleanTarget}) + if _, err := h.evolutionStore.CreateSkillVersion(r.Context(), store.SkillVersion{ + SkillID: skillID, + Version: newVersion, + ContentHash: hash, + ChangedFiles: changedFiles, + CreatedByActorType: "user", + CreatedByActorID: store.ActorIDFromContext(r.Context()), + CreatedFromSuggestionID: &sg.ID, + }); err != nil { + return nil, fmt.Errorf("record skill version: %w", err) + } + applied, err := h.evolutionStore.MarkSuggestionApplied(r.Context(), sg.ID, newVersion, "user", store.ActorIDFromContext(r.Context())) + if err != nil { + return nil, err + } + h.logSkillActivity(r, "skill.suggestion.applied", skillID, map[string]any{ + "suggestion_id": sg.ID.String(), + "changed_files": []string{cleanTarget}, + "old_version": oldVersion, + "new_version": newVersion, + "content_hash": hash, + }) + cleanup = false + return applied, nil +} + +func (h *SkillsHandler) logSkillActivity(r *http.Request, action string, skillID uuid.UUID, details map[string]any) { + if h.activityStore == nil { + return + } + raw, _ := json.Marshal(details) + _ = h.activityStore.Log(r.Context(), &store.ActivityLog{ + ActorType: "user", + ActorID: store.ActorIDFromContext(r.Context()), + Action: action, + EntityType: "skill", + EntityID: skillID.String(), + Details: raw, + IPAddress: r.RemoteAddr, + }) +} + +func clampLimit(raw string, fallback, max int) int { + if raw == "" { + return fallback + } + var n int + if _, err := fmt.Sscanf(raw, "%d", &n); err != nil || n <= 0 { + return fallback + } + if n > max { + return max + } + return n +} + +func copyDir(src, dst string) error { + return filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + target := filepath.Join(dst, rel) + if d.IsDir() { + return os.MkdirAll(target, 0755) + } + if d.Type()&os.ModeSymlink != 0 { + return nil + } + in, err := os.Open(path) + if err != nil { + return err + } + defer in.Close() + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, in) + return err + }) +} + +func hashSkillDir(dir string) (string, int64, error) { + var size int64 + h := sha256.New() + err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || d.Type()&os.ModeSymlink != 0 { + return nil + } + info, err := d.Info() + if err != nil { + return err + } + size += info.Size() + rel, _ := filepath.Rel(dir, path) + h.Write([]byte(filepath.ToSlash(rel))) + data, err := os.ReadFile(path) + if err != nil { + return err + } + h.Write(data) + return nil + }) + if err != nil { + return "", 0, err + } + return fmt.Sprintf("%x", h.Sum(nil)), size, nil +} diff --git a/internal/http/skills_evolution_test.go b/internal/http/skills_evolution_test.go new file mode 100644 index 00000000..c2d59573 --- /dev/null +++ b/internal/http/skills_evolution_test.go @@ -0,0 +1,131 @@ +package http + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func TestApplySkillSuggestionPatchCreatesNewReferenceFile(t *testing.T) { + handler, skillStore, ctx, root := newTestUploadHandler(t) + evolution := &skillEvolutionStoreStub{} + handler.SetEvolutionStore(evolution, nil) + + currentDir := filepath.Join(root, "skills-store", "reference-skill", "1") + if err := os.MkdirAll(currentDir, 0755); err != nil { + t.Fatalf("mkdir current skill: %v", err) + } + if err := os.WriteFile(filepath.Join(currentDir, "SKILL.md"), []byte(skillMarkdown("Reference Skill", "reference-skill")), 0644); err != nil { + t.Fatalf("write skill: %v", err) + } + skillID := skillStore.seedCustomSkill("reference-skill", currentDir, "active", nil) + content := "Use the documented query syntax.\n" + patch, err := json.Marshal(skillDraftPatch{Content: &content}) + if err != nil { + t.Fatalf("marshal draft patch: %v", err) + } + suggestion := &store.SkillImprovementSuggestion{ + ID: uuid.New(), + SkillID: skillID, + TargetFile: "references/troubleshooting.md", + DraftPatch: patch, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + SkillSlug: "reference-skill", + Status: store.SkillSuggestionStatusApproved, + SuggestionType: "skill_reference_add", + } + req := httptest.NewRequest(http.MethodPost, "/v1/skills/"+skillID.String()+"/evolution/suggestions/"+suggestion.ID.String()+"/apply", nil).WithContext(ctx) + + applied, err := handler.applySkillSuggestionPatch(req, skillID, suggestion) + if err != nil { + t.Fatalf("apply suggestion: %v", err) + } + if applied.Status != store.SkillSuggestionStatusApplied { + t.Fatalf("status = %q, want applied", applied.Status) + } + + newReference := filepath.Join(root, "skills-store", "reference-skill", "2", "references", "troubleshooting.md") + got, err := os.ReadFile(newReference) + if err != nil { + t.Fatalf("read created reference: %v", err) + } + if string(got) != content { + t.Fatalf("created reference = %q, want %q", got, content) + } + if len(evolution.versions) != 1 || evolution.versions[0].Version != 2 { + t.Fatalf("versions = %+v, want one version 2", evolution.versions) + } +} + +type skillEvolutionStoreStub struct { + versions []store.SkillVersion +} + +func (s *skillEvolutionStoreStub) GetSettings(context.Context, uuid.UUID) (*store.SkillEvolutionSettings, error) { + return nil, nil +} + +func (s *skillEvolutionStoreStub) UpsertSettings(context.Context, store.SkillEvolutionSettings) (*store.SkillEvolutionSettings, error) { + return nil, nil +} + +func (s *skillEvolutionStoreStub) RecordUsage(context.Context, store.SkillUsageMetric) error { + return nil +} + +func (s *skillEvolutionStoreStub) AggregateUsage(context.Context, uuid.UUID, *time.Time) (*store.SkillUsageStats, error) { + return nil, nil +} + +func (s *skillEvolutionStoreStub) ListUsage(context.Context, uuid.UUID, int) ([]store.SkillUsageMetric, error) { + return nil, nil +} + +func (s *skillEvolutionStoreStub) CreateSuggestion(context.Context, store.SkillImprovementSuggestion) (*store.SkillImprovementSuggestion, error) { + return nil, nil +} + +func (s *skillEvolutionStoreStub) ListSuggestions(context.Context, uuid.UUID, string, int) ([]store.SkillImprovementSuggestion, error) { + return nil, nil +} + +func (s *skillEvolutionStoreStub) GetSuggestion(context.Context, uuid.UUID) (*store.SkillImprovementSuggestion, error) { + return nil, nil +} + +func (s *skillEvolutionStoreStub) UpdateSuggestionStatus(context.Context, uuid.UUID, string, string, string) (*store.SkillImprovementSuggestion, error) { + return nil, nil +} + +func (s *skillEvolutionStoreStub) MarkSuggestionApplied(_ context.Context, id uuid.UUID, version int, actorType, actorID string) (*store.SkillImprovementSuggestion, error) { + return &store.SkillImprovementSuggestion{ + ID: id, + Status: store.SkillSuggestionStatusApplied, + ReviewedByActorType: actorType, + ReviewedByActorID: actorID, + AppliedVersion: &version, + }, nil +} + +func (s *skillEvolutionStoreStub) CreateSkillVersion(_ context.Context, version store.SkillVersion) (*store.SkillVersion, error) { + s.versions = append(s.versions, version) + return &version, nil +} + +func (s *skillEvolutionStoreStub) ListSkillVersions(context.Context, uuid.UUID, int) ([]store.SkillVersion, error) { + return s.versions, nil +} + +func (s *skillEvolutionStoreStub) GetSkillVersion(context.Context, uuid.UUID, int) (*store.SkillVersion, error) { + return nil, nil +} diff --git a/internal/http/skills_upload_test.go b/internal/http/skills_upload_test.go index de45592c..9240d8a6 100644 --- a/internal/http/skills_upload_test.go +++ b/internal/http/skills_upload_test.go @@ -939,6 +939,9 @@ func (s *skillManageStoreStub) seedCustomSkill(slug, dir, status string, missing func (s *skillManageStoreStub) seedCustomSkillForTenant(tenantID uuid.UUID, slug, dir, status string, missing []string) uuid.UUID { id := uuid.New() + if s.nextBySlug[slug] < 1 { + s.nextBySlug[slug] = 1 + } s.skills[id] = store.SkillInfo{ ID: id.String(), TenantID: tenantID.String(), @@ -1046,6 +1049,16 @@ func (s *skillManageStoreStub) UpdateSkill(ctx context.Context, id uuid.UUID, up if visibility, ok := updates["visibility"].(string); ok { skill.Visibility = visibility } + if version, ok := updates["version"].(int); ok { + skill.Version = version + if version > s.nextBySlug[skill.Slug] { + s.nextBySlug[skill.Slug] = version + } + } + if filePath, ok := updates["file_path"].(string); ok { + skill.BaseDir = filePath + skill.Path = filepath.Join(filePath, "SKILL.md") + } s.lastUpdates[id] = maps.Clone(updates) s.skills[id] = skill return nil @@ -1156,8 +1169,12 @@ func (s *skillManageStoreStub) ListAgentGrantsForSkill(context.Context, uuid.UUI func (s *skillManageStoreStub) AgentCanManageSkill(context.Context, uuid.UUID, uuid.UUID) (bool, error) { return false, nil } -func (s *skillManageStoreStub) GetSkillFilePath(context.Context, uuid.UUID) (string, string, int, bool, bool) { - return "", "", 0, false, false +func (s *skillManageStoreStub) GetSkillFilePath(ctx context.Context, id uuid.UUID) (string, string, int, bool, bool) { + skill, ok := s.skills[id] + if !ok || !s.canAccessSkill(ctx, skill) { + return "", "", 0, false, false + } + return skill.BaseDir, skill.Slug, skill.Version, skill.IsSystem, true } // --------------------------------------------------------------------------- diff --git a/internal/skills/target_path_validator.go b/internal/skills/target_path_validator.go new file mode 100644 index 00000000..4f38df40 --- /dev/null +++ b/internal/skills/target_path_validator.go @@ -0,0 +1,50 @@ +package skills + +import ( + "fmt" + "path" + "strings" +) + +// ValidateSkillTargetPath validates a relative path inside a managed skill root. +// Set allowSkillMD when the caller is explicitly editing the primary SKILL.md. +func ValidateSkillTargetPath(rawPath string, allowSkillMD bool) (string, error) { + if rawPath == "" { + return "", fmt.Errorf("invalid file path %q: empty path", rawPath) + } + if strings.ContainsRune(rawPath, 0x00) { + return "", fmt.Errorf("invalid file path %q: null byte", rawPath) + } + if len(rawPath) >= 2 && rawPath[1] == ':' { + return "", fmt.Errorf("invalid file path %q: windows drive paths are not allowed", rawPath) + } + normalized := strings.ReplaceAll(rawPath, "\\", "/") + if strings.HasPrefix(normalized, "/") { + return "", fmt.Errorf("invalid file path %q: absolute paths are not allowed", rawPath) + } + for part := range strings.SplitSeq(normalized, "/") { + switch part { + case "..": + return "", fmt.Errorf("invalid file path %q: parent traversal is not allowed", rawPath) + case ".git": + return "", fmt.Errorf("invalid file path %q: system artifact paths are not allowed", rawPath) + } + if strings.HasPrefix(part, ".") { + return "", fmt.Errorf("invalid file path %q: hidden files are not allowed", rawPath) + } + } + cleanPath := path.Clean(normalized) + if cleanPath == "." || strings.HasPrefix(cleanPath, "../") || cleanPath == ".." || strings.HasPrefix(cleanPath, "/") { + return "", fmt.Errorf("invalid file path %q: path escapes skill root", rawPath) + } + if strings.EqualFold(cleanPath, "SKILL.md") { + if allowSkillMD { + return "SKILL.md", nil + } + return "", fmt.Errorf("invalid file path %q: SKILL.md must be provided via content or find/replace", rawPath) + } + if IsSystemArtifact(cleanPath) { + return "", fmt.Errorf("invalid file path %q: system artifact paths are not allowed", rawPath) + } + return cleanPath, nil +} diff --git a/internal/skills/target_path_validator_test.go b/internal/skills/target_path_validator_test.go new file mode 100644 index 00000000..29ca86a7 --- /dev/null +++ b/internal/skills/target_path_validator_test.go @@ -0,0 +1,40 @@ +package skills + +import "testing" + +func TestValidateSkillTargetPath(t *testing.T) { + tests := []struct { + name string + raw string + allowSkillMD bool + want string + wantErr bool + }{ + {name: "reference file", raw: `references\troubleshooting.md`, want: "references/troubleshooting.md"}, + {name: "skill markdown allowed", raw: "SKILL.md", allowSkillMD: true, want: "SKILL.md"}, + {name: "skill markdown blocked", raw: "SKILL.md", wantErr: true}, + {name: "absolute path", raw: "/tmp/SKILL.md", wantErr: true}, + {name: "parent traversal", raw: "../outside.md", wantErr: true}, + {name: "hidden file", raw: "references/.env", wantErr: true}, + {name: "system artifact", raw: ".git/config", wantErr: true}, + {name: "windows drive", raw: "C:/Users/secret.md", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ValidateSkillTargetPath(tt.raw, tt.allowSkillMD) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got path %q", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("got %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/store/pg/factory.go b/internal/store/pg/factory.go index ea2bc565..f826f9f2 100644 --- a/internal/store/pg/factory.go +++ b/internal/store/pg/factory.go @@ -55,6 +55,7 @@ func NewPGStores(cfg store.StoreConfig) (*store.Stores, error) { Tenants: NewPGTenantStore(db), BuiltinToolTenantCfgs: NewPGBuiltinToolTenantConfigStore(db), SkillTenantCfgs: NewPGSkillTenantConfigStore(db), + SkillEvolution: NewPGSkillEvolutionStore(db), SystemConfigs: NewPGSystemConfigStore(db), SubagentTasks: NewPGSubagentTaskStore(db), Vault: NewPGVaultStore(db), diff --git a/internal/store/pg/skill_evolution.go b/internal/store/pg/skill_evolution.go new file mode 100644 index 00000000..445456ab --- /dev/null +++ b/internal/store/pg/skill_evolution.go @@ -0,0 +1,440 @@ +package pg + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +type PGSkillEvolutionStore struct { + db *sql.DB +} + +func NewPGSkillEvolutionStore(db *sql.DB) *PGSkillEvolutionStore { + return &PGSkillEvolutionStore{db: db} +} + +func (s *PGSkillEvolutionStore) resolveSkill(ctx context.Context, skillID uuid.UUID) (string, int, error) { + tenantID := tenantIDForInsert(ctx) + var slug string + var version int + var skillTenant uuid.UUID + var isSystem bool + err := s.db.QueryRowContext(ctx, + `SELECT slug, version, tenant_id, is_system + FROM skills + WHERE id = $1 AND status != 'deleted'`, + skillID, + ).Scan(&slug, &version, &skillTenant, &isSystem) + if errors.Is(err, sql.ErrNoRows) { + return "", 0, fmt.Errorf("skill not found") + } + if err != nil { + return "", 0, err + } + if !store.IsCrossTenant(ctx) && !isSystem && skillTenant != tenantID { + return "", 0, fmt.Errorf("skill not found") + } + return slug, version, nil +} + +func (s *PGSkillEvolutionStore) GetSettings(ctx context.Context, skillID uuid.UUID) (*store.SkillEvolutionSettings, error) { + if _, _, err := s.resolveSkill(ctx, skillID); err != nil { + return nil, err + } + tenantID := tenantIDForInsert(ctx) + var out store.SkillEvolutionSettings + err := s.db.QueryRowContext(ctx, + `SELECT tenant_id, skill_id, enabled, mode, last_analyzed_at, created_at, updated_at + FROM skill_evolution_settings + WHERE tenant_id = $1 AND skill_id = $2`, + tenantID, skillID, + ).Scan(&out.TenantID, &out.SkillID, &out.Enabled, &out.Mode, &out.LastAnalyzedAt, &out.CreatedAt, &out.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return &store.SkillEvolutionSettings{ + TenantID: tenantID, + SkillID: skillID, + Enabled: false, + Mode: store.SkillEvolutionModeSuggestOnly, + }, nil + } + if err != nil { + return nil, err + } + return &out, nil +} + +func (s *PGSkillEvolutionStore) UpsertSettings(ctx context.Context, settings store.SkillEvolutionSettings) (*store.SkillEvolutionSettings, error) { + if _, _, err := s.resolveSkill(ctx, settings.SkillID); err != nil { + return nil, err + } + tenantID := tenantIDForInsert(ctx) + mode := strings.TrimSpace(settings.Mode) + if mode == "" { + mode = store.SkillEvolutionModeSuggestOnly + } + var out store.SkillEvolutionSettings + err := s.db.QueryRowContext(ctx, + `INSERT INTO skill_evolution_settings (tenant_id, skill_id, enabled, mode, last_analyzed_at) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (tenant_id, skill_id) DO UPDATE SET + enabled = EXCLUDED.enabled, + mode = EXCLUDED.mode, + last_analyzed_at = EXCLUDED.last_analyzed_at, + updated_at = NOW() + RETURNING tenant_id, skill_id, enabled, mode, last_analyzed_at, created_at, updated_at`, + tenantID, settings.SkillID, settings.Enabled, mode, settings.LastAnalyzedAt, + ).Scan(&out.TenantID, &out.SkillID, &out.Enabled, &out.Mode, &out.LastAnalyzedAt, &out.CreatedAt, &out.UpdatedAt) + if err != nil { + return nil, err + } + return &out, nil +} + +func (s *PGSkillEvolutionStore) RecordUsage(ctx context.Context, metric store.SkillUsageMetric) error { + slug, version, err := s.resolveSkill(ctx, metric.SkillID) + if err != nil { + return err + } + tenantID := tenantIDForInsert(ctx) + if metric.ID == uuid.Nil { + metric.ID = uuid.New() + } + if metric.SkillSlug == "" { + metric.SkillSlug = slug + } + if metric.SkillVersion == 0 { + metric.SkillVersion = version + } + if metric.Status == "" { + metric.Status = store.SkillUsageStatusStarted + } + _, err = s.db.ExecContext(ctx, + `INSERT INTO skill_usage_metrics + (id, tenant_id, skill_id, skill_slug, skill_version, agent_id, user_id, session_key, + trace_id, invocation_id, invocation_source, status, failure_reason, tool_calls_count, duration_ms) + VALUES ($1, $2, $3, $4, $5, NULLIF($6, $16), $7, $8, $9, $10, $11, $12, $13, $14, $15)`, + metric.ID, tenantID, metric.SkillID, metric.SkillSlug, metric.SkillVersion, + metric.AgentID, metric.UserID, metric.SessionKey, metric.TraceID, metric.InvocationID, + metric.InvocationSource, metric.Status, metric.FailureReason, metric.ToolCallsCount, metric.DurationMs, uuid.Nil, + ) + return err +} + +func (s *PGSkillEvolutionStore) AggregateUsage(ctx context.Context, skillID uuid.UUID, since *time.Time) (*store.SkillUsageStats, error) { + if _, _, err := s.resolveSkill(ctx, skillID); err != nil { + return nil, err + } + tenantID := tenantIDForInsert(ctx) + where := "tenant_id = $1 AND skill_id = $2" + args := []any{tenantID, skillID} + if since != nil { + where += " AND created_at >= $3" + args = append(args, *since) + } + query := fmt.Sprintf( + `SELECT COUNT(*), + COUNT(*) FILTER (WHERE status = 'started'), + COUNT(*) FILTER (WHERE status = 'succeeded'), + COUNT(*) FILTER (WHERE status = 'failed'), + COUNT(*) FILTER (WHERE status = 'abandoned'), + MAX(created_at) + FROM skill_usage_metrics WHERE %s`, + where, + ) + var out store.SkillUsageStats + out.SkillID = skillID + var last sql.NullTime + if err := s.db.QueryRowContext(ctx, query, args...).Scan(&out.TotalCalls, &out.Started, &out.Succeeded, &out.Failed, &out.Abandoned, &last); err != nil { + return nil, err + } + if last.Valid { + out.LastUsedAt = &last.Time + } + if out.TotalCalls > 0 { + out.SuccessRate = float64(out.Succeeded) / float64(out.TotalCalls) + out.FailureRate = float64(out.Failed) / float64(out.TotalCalls) + } + reasonQuery := fmt.Sprintf( + `SELECT failure_reason, COUNT(*), MAX(created_at) + FROM skill_usage_metrics + WHERE %s AND status = 'failed' AND COALESCE(failure_reason, '') != '' + GROUP BY failure_reason ORDER BY COUNT(*) DESC, MAX(created_at) DESC LIMIT 5`, + where, + ) + rows, err := s.db.QueryContext(ctx, reasonQuery, args...) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var r store.SkillFailureReason + if err := rows.Scan(&r.Reason, &r.Count, &r.LastSeen); err != nil { + return nil, err + } + out.TopFailureReasons = append(out.TopFailureReasons, r) + } + return &out, rows.Err() +} + +func (s *PGSkillEvolutionStore) ListUsage(ctx context.Context, skillID uuid.UUID, limit int) ([]store.SkillUsageMetric, error) { + if _, _, err := s.resolveSkill(ctx, skillID); err != nil { + return nil, err + } + if limit <= 0 { + limit = 50 + } + tenantID := tenantIDForInsert(ctx) + rows, err := s.db.QueryContext(ctx, + `SELECT id, tenant_id, skill_id, skill_slug, skill_version, COALESCE(agent_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(user_id,''), COALESCE(session_key,''), COALESCE(trace_id,''), COALESCE(invocation_id,''), + invocation_source, status, COALESCE(failure_reason,''), tool_calls_count, duration_ms, created_at + FROM skill_usage_metrics + WHERE tenant_id = $1 AND skill_id = $2 + ORDER BY created_at DESC LIMIT $3`, + tenantID, skillID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []store.SkillUsageMetric + for rows.Next() { + var m store.SkillUsageMetric + if err := rows.Scan(&m.ID, &m.TenantID, &m.SkillID, &m.SkillSlug, &m.SkillVersion, &m.AgentID, + &m.UserID, &m.SessionKey, &m.TraceID, &m.InvocationID, &m.InvocationSource, &m.Status, + &m.FailureReason, &m.ToolCallsCount, &m.DurationMs, &m.CreatedAt); err != nil { + return nil, err + } + out = append(out, m) + } + return out, rows.Err() +} + +func (s *PGSkillEvolutionStore) CreateSuggestion(ctx context.Context, sg store.SkillImprovementSuggestion) (*store.SkillImprovementSuggestion, error) { + slug, _, err := s.resolveSkill(ctx, sg.SkillID) + if err != nil { + return nil, err + } + if sg.ID == uuid.Nil { + sg.ID = uuid.New() + } + if sg.SkillSlug == "" { + sg.SkillSlug = slug + } + if sg.Status == "" { + sg.Status = store.SkillSuggestionStatusPending + } + tenantID := tenantIDForInsert(ctx) + var out store.SkillImprovementSuggestion + err = s.db.QueryRowContext(ctx, + `INSERT INTO skill_improvement_suggestions + (id, tenant_id, skill_id, skill_slug, suggestion_type, status, reason, evidence, + draft_patch, target_file, created_by_actor_type, created_by_actor_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, COALESCE($8, '{}'::jsonb), COALESCE($9, '{}'::jsonb), $10, $11, $12) + RETURNING id, tenant_id, skill_id, skill_slug, suggestion_type, status, reason, evidence, draft_patch, + COALESCE(target_file,''), COALESCE(created_by_actor_type,''), COALESCE(created_by_actor_id,''), + COALESCE(reviewed_by_actor_type,''), COALESCE(reviewed_by_actor_id,''), reviewed_at, applied_version, + created_at, updated_at`, + sg.ID, tenantID, sg.SkillID, sg.SkillSlug, sg.SuggestionType, sg.Status, sg.Reason, + jsonOrEmpty(sg.Evidence), jsonOrEmpty(sg.DraftPatch), sg.TargetFile, sg.CreatedByActorType, sg.CreatedByActorID, + ).Scan(&out.ID, &out.TenantID, &out.SkillID, &out.SkillSlug, &out.SuggestionType, &out.Status, &out.Reason, + &out.Evidence, &out.DraftPatch, &out.TargetFile, &out.CreatedByActorType, &out.CreatedByActorID, + &out.ReviewedByActorType, &out.ReviewedByActorID, &out.ReviewedAt, &out.AppliedVersion, + &out.CreatedAt, &out.UpdatedAt) + if err != nil { + return nil, err + } + return &out, nil +} + +func (s *PGSkillEvolutionStore) ListSuggestions(ctx context.Context, skillID uuid.UUID, status string, limit int) ([]store.SkillImprovementSuggestion, error) { + if _, _, err := s.resolveSkill(ctx, skillID); err != nil { + return nil, err + } + if limit <= 0 { + limit = 50 + } + tenantID := tenantIDForInsert(ctx) + q := `SELECT id, tenant_id, skill_id, skill_slug, suggestion_type, status, reason, evidence, draft_patch, + COALESCE(target_file,''), COALESCE(created_by_actor_type,''), COALESCE(created_by_actor_id,''), + COALESCE(reviewed_by_actor_type,''), COALESCE(reviewed_by_actor_id,''), reviewed_at, applied_version, + created_at, updated_at + FROM skill_improvement_suggestions WHERE tenant_id = $1 AND skill_id = $2` + args := []any{tenantID, skillID} + if status != "" { + args = append(args, status) + q += fmt.Sprintf(" AND status = $%d", len(args)) + } + args = append(args, limit) + q += fmt.Sprintf(" ORDER BY created_at DESC LIMIT $%d", len(args)) + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + return scanPGSuggestions(rows) +} + +func (s *PGSkillEvolutionStore) GetSuggestion(ctx context.Context, id uuid.UUID) (*store.SkillImprovementSuggestion, error) { + tenantID := tenantIDForInsert(ctx) + rows, err := s.db.QueryContext(ctx, + `SELECT id, tenant_id, skill_id, skill_slug, suggestion_type, status, reason, evidence, draft_patch, + COALESCE(target_file,''), COALESCE(created_by_actor_type,''), COALESCE(created_by_actor_id,''), + COALESCE(reviewed_by_actor_type,''), COALESCE(reviewed_by_actor_id,''), reviewed_at, applied_version, + created_at, updated_at + FROM skill_improvement_suggestions WHERE tenant_id = $1 AND id = $2`, + tenantID, id) + if err != nil { + return nil, err + } + defer rows.Close() + items, err := scanPGSuggestions(rows) + if err != nil || len(items) == 0 { + return nil, err + } + if _, _, err := s.resolveSkill(ctx, items[0].SkillID); err != nil { + return nil, err + } + return &items[0], nil +} + +func (s *PGSkillEvolutionStore) UpdateSuggestionStatus(ctx context.Context, id uuid.UUID, status, actorType, actorID string) (*store.SkillImprovementSuggestion, error) { + tenantID := tenantIDForInsert(ctx) + res, err := s.db.ExecContext(ctx, + `UPDATE skill_improvement_suggestions + SET status = $1, reviewed_by_actor_type = $2, reviewed_by_actor_id = $3, reviewed_at = NOW(), updated_at = NOW() + WHERE tenant_id = $4 AND id = $5`, + status, actorType, actorID, tenantID, id) + if err != nil { + return nil, err + } + if n, _ := res.RowsAffected(); n == 0 { + return nil, fmt.Errorf("suggestion not found") + } + return s.GetSuggestion(ctx, id) +} + +func (s *PGSkillEvolutionStore) MarkSuggestionApplied(ctx context.Context, id uuid.UUID, version int, actorType, actorID string) (*store.SkillImprovementSuggestion, error) { + tenantID := tenantIDForInsert(ctx) + res, err := s.db.ExecContext(ctx, + `UPDATE skill_improvement_suggestions + SET status = 'applied', applied_version = $1, reviewed_by_actor_type = $2, + reviewed_by_actor_id = $3, reviewed_at = COALESCE(reviewed_at, NOW()), updated_at = NOW() + WHERE tenant_id = $4 AND id = $5`, + version, actorType, actorID, tenantID, id) + if err != nil { + return nil, err + } + if n, _ := res.RowsAffected(); n == 0 { + return nil, fmt.Errorf("suggestion not found") + } + return s.GetSuggestion(ctx, id) +} + +func (s *PGSkillEvolutionStore) CreateSkillVersion(ctx context.Context, v store.SkillVersion) (*store.SkillVersion, error) { + if _, _, err := s.resolveSkill(ctx, v.SkillID); err != nil { + return nil, err + } + if v.ID == uuid.Nil { + v.ID = uuid.New() + } + tenantID := tenantIDForInsert(ctx) + var out store.SkillVersion + err := s.db.QueryRowContext(ctx, + `INSERT INTO skill_versions + (id, tenant_id, skill_id, version, content_hash, changed_files, created_by_actor_type, + created_by_actor_id, created_from_suggestion_id) + VALUES ($1, $2, $3, $4, $5, COALESCE($6, '[]'::jsonb), $7, $8, $9) + RETURNING id, tenant_id, skill_id, version, content_hash, changed_files, + COALESCE(created_by_actor_type,''), COALESCE(created_by_actor_id,''), + created_from_suggestion_id, created_at`, + v.ID, tenantID, v.SkillID, v.Version, v.ContentHash, jsonOrEmptyArray(v.ChangedFiles), + v.CreatedByActorType, v.CreatedByActorID, v.CreatedFromSuggestionID, + ).Scan(&out.ID, &out.TenantID, &out.SkillID, &out.Version, &out.ContentHash, &out.ChangedFiles, + &out.CreatedByActorType, &out.CreatedByActorID, &out.CreatedFromSuggestionID, &out.CreatedAt) + if err != nil { + return nil, err + } + return &out, nil +} + +func (s *PGSkillEvolutionStore) ListSkillVersions(ctx context.Context, skillID uuid.UUID, limit int) ([]store.SkillVersion, error) { + if _, _, err := s.resolveSkill(ctx, skillID); err != nil { + return nil, err + } + if limit <= 0 { + limit = 50 + } + tenantID := tenantIDForInsert(ctx) + rows, err := s.db.QueryContext(ctx, + `SELECT id, tenant_id, skill_id, version, content_hash, changed_files, + COALESCE(created_by_actor_type,''), COALESCE(created_by_actor_id,''), + created_from_suggestion_id, created_at + FROM skill_versions WHERE tenant_id = $1 AND skill_id = $2 + ORDER BY version DESC LIMIT $3`, + tenantID, skillID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + return scanPGSkillVersions(rows) +} + +func (s *PGSkillEvolutionStore) GetSkillVersion(ctx context.Context, skillID uuid.UUID, version int) (*store.SkillVersion, error) { + if _, _, err := s.resolveSkill(ctx, skillID); err != nil { + return nil, err + } + tenantID := tenantIDForInsert(ctx) + rows, err := s.db.QueryContext(ctx, + `SELECT id, tenant_id, skill_id, version, content_hash, changed_files, + COALESCE(created_by_actor_type,''), COALESCE(created_by_actor_id,''), + created_from_suggestion_id, created_at + FROM skill_versions WHERE tenant_id = $1 AND skill_id = $2 AND version = $3`, + tenantID, skillID, version) + if err != nil { + return nil, err + } + defer rows.Close() + items, err := scanPGSkillVersions(rows) + if err != nil || len(items) == 0 { + return nil, err + } + return &items[0], nil +} + +func scanPGSuggestions(rows *sql.Rows) ([]store.SkillImprovementSuggestion, error) { + var out []store.SkillImprovementSuggestion + for rows.Next() { + var sg store.SkillImprovementSuggestion + if err := rows.Scan(&sg.ID, &sg.TenantID, &sg.SkillID, &sg.SkillSlug, &sg.SuggestionType, &sg.Status, + &sg.Reason, &sg.Evidence, &sg.DraftPatch, &sg.TargetFile, &sg.CreatedByActorType, &sg.CreatedByActorID, + &sg.ReviewedByActorType, &sg.ReviewedByActorID, &sg.ReviewedAt, &sg.AppliedVersion, + &sg.CreatedAt, &sg.UpdatedAt); err != nil { + return nil, err + } + out = append(out, sg) + } + return out, rows.Err() +} + +func scanPGSkillVersions(rows *sql.Rows) ([]store.SkillVersion, error) { + var out []store.SkillVersion + for rows.Next() { + var v store.SkillVersion + if err := rows.Scan(&v.ID, &v.TenantID, &v.SkillID, &v.Version, &v.ContentHash, &v.ChangedFiles, + &v.CreatedByActorType, &v.CreatedByActorID, &v.CreatedFromSuggestionID, &v.CreatedAt); err != nil { + return nil, err + } + out = append(out, v) + } + return out, rows.Err() +} + +var _ store.SkillEvolutionStore = (*PGSkillEvolutionStore)(nil) diff --git a/internal/store/skill_evolution_store.go b/internal/store/skill_evolution_store.go new file mode 100644 index 00000000..65043c20 --- /dev/null +++ b/internal/store/skill_evolution_store.go @@ -0,0 +1,124 @@ +package store + +import ( + "context" + "encoding/json" + "time" + + "github.com/google/uuid" +) + +const ( + SkillEvolutionModeSuggestOnly = "suggest_only" + SkillEvolutionModeAutoAnalyze = "auto_analyze" + + SkillUsageStatusStarted = "started" + SkillUsageStatusSucceeded = "succeeded" + SkillUsageStatusFailed = "failed" + SkillUsageStatusAbandoned = "abandoned" + + SkillSuggestionStatusPending = "pending" + SkillSuggestionStatusApproved = "approved" + SkillSuggestionStatusRejected = "rejected" + SkillSuggestionStatusApplied = "applied" +) + +// SkillEvolutionSettings stores tenant-scoped self-evolution controls for one skill. +type SkillEvolutionSettings struct { + TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"` + SkillID uuid.UUID `json:"skill_id" db:"skill_id"` + Enabled bool `json:"enabled" db:"enabled"` + Mode string `json:"mode" db:"mode"` + LastAnalyzedAt *time.Time `json:"last_analyzed_at,omitempty" db:"last_analyzed_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// SkillUsageMetric is a durable usage event emitted by trusted runtime paths. +type SkillUsageMetric struct { + ID uuid.UUID `json:"id" db:"id"` + TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"` + SkillID uuid.UUID `json:"skill_id" db:"skill_id"` + SkillSlug string `json:"skill_slug" db:"skill_slug"` + SkillVersion int `json:"skill_version" db:"skill_version"` + AgentID uuid.UUID `json:"agent_id,omitempty" db:"agent_id"` + UserID string `json:"user_id,omitempty" db:"user_id"` + SessionKey string `json:"session_key,omitempty" db:"session_key"` + TraceID string `json:"trace_id,omitempty" db:"trace_id"` + InvocationID string `json:"invocation_id,omitempty" db:"invocation_id"` + InvocationSource string `json:"invocation_source" db:"invocation_source"` + Status string `json:"status" db:"status"` + FailureReason string `json:"failure_reason,omitempty" db:"failure_reason"` + ToolCallsCount int `json:"tool_calls_count" db:"tool_calls_count"` + DurationMs int64 `json:"duration_ms" db:"duration_ms"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +type SkillFailureReason struct { + Reason string `json:"reason"` + Count int `json:"count"` + LastSeen time.Time `json:"last_seen"` +} + +type SkillUsageStats struct { + SkillID uuid.UUID `json:"skill_id"` + TotalCalls int `json:"total_calls"` + Started int `json:"started"` + Succeeded int `json:"succeeded"` + Failed int `json:"failed"` + Abandoned int `json:"abandoned"` + SuccessRate float64 `json:"success_rate"` + FailureRate float64 `json:"failure_rate"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + TopFailureReasons []SkillFailureReason `json:"top_failure_reasons,omitempty"` +} + +type SkillImprovementSuggestion struct { + ID uuid.UUID `json:"id" db:"id"` + TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"` + SkillID uuid.UUID `json:"skill_id" db:"skill_id"` + SkillSlug string `json:"skill_slug" db:"skill_slug"` + SuggestionType string `json:"suggestion_type" db:"suggestion_type"` + Status string `json:"status" db:"status"` + Reason string `json:"reason" db:"reason"` + Evidence json.RawMessage `json:"evidence,omitempty" db:"evidence"` + DraftPatch json.RawMessage `json:"draft_patch,omitempty" db:"draft_patch"` + TargetFile string `json:"target_file,omitempty" db:"target_file"` + CreatedByActorType string `json:"created_by_actor_type,omitempty" db:"created_by_actor_type"` + CreatedByActorID string `json:"created_by_actor_id,omitempty" db:"created_by_actor_id"` + ReviewedByActorType string `json:"reviewed_by_actor_type,omitempty" db:"reviewed_by_actor_type"` + ReviewedByActorID string `json:"reviewed_by_actor_id,omitempty" db:"reviewed_by_actor_id"` + ReviewedAt *time.Time `json:"reviewed_at,omitempty" db:"reviewed_at"` + AppliedVersion *int `json:"applied_version,omitempty" db:"applied_version"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +type SkillVersion struct { + ID uuid.UUID `json:"id" db:"id"` + TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"` + SkillID uuid.UUID `json:"skill_id" db:"skill_id"` + Version int `json:"version" db:"version"` + ContentHash string `json:"content_hash" db:"content_hash"` + ChangedFiles json.RawMessage `json:"changed_files,omitempty" db:"changed_files"` + CreatedByActorType string `json:"created_by_actor_type,omitempty" db:"created_by_actor_type"` + CreatedByActorID string `json:"created_by_actor_id,omitempty" db:"created_by_actor_id"` + CreatedFromSuggestionID *uuid.UUID `json:"created_from_suggestion_id,omitempty" db:"created_from_suggestion_id"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +type SkillEvolutionStore interface { + GetSettings(ctx context.Context, skillID uuid.UUID) (*SkillEvolutionSettings, error) + UpsertSettings(ctx context.Context, settings SkillEvolutionSettings) (*SkillEvolutionSettings, error) + RecordUsage(ctx context.Context, metric SkillUsageMetric) error + AggregateUsage(ctx context.Context, skillID uuid.UUID, since *time.Time) (*SkillUsageStats, error) + ListUsage(ctx context.Context, skillID uuid.UUID, limit int) ([]SkillUsageMetric, error) + CreateSuggestion(ctx context.Context, suggestion SkillImprovementSuggestion) (*SkillImprovementSuggestion, error) + ListSuggestions(ctx context.Context, skillID uuid.UUID, status string, limit int) ([]SkillImprovementSuggestion, error) + GetSuggestion(ctx context.Context, id uuid.UUID) (*SkillImprovementSuggestion, error) + UpdateSuggestionStatus(ctx context.Context, id uuid.UUID, status, actorType, actorID string) (*SkillImprovementSuggestion, error) + MarkSuggestionApplied(ctx context.Context, id uuid.UUID, version int, actorType, actorID string) (*SkillImprovementSuggestion, error) + CreateSkillVersion(ctx context.Context, version SkillVersion) (*SkillVersion, error) + ListSkillVersions(ctx context.Context, skillID uuid.UUID, limit int) ([]SkillVersion, error) + GetSkillVersion(ctx context.Context, skillID uuid.UUID, version int) (*SkillVersion, error) +} diff --git a/internal/store/sqlitestore/factory.go b/internal/store/sqlitestore/factory.go index 284c4c1a..ba90f5e8 100644 --- a/internal/store/sqlitestore/factory.go +++ b/internal/store/sqlitestore/factory.go @@ -48,6 +48,7 @@ func NewSQLiteStores(cfg store.StoreConfig) (*store.Stores, error) { Tenants: NewSQLiteTenantStore(db), BuiltinToolTenantCfgs: NewSQLiteBuiltinToolTenantConfigStore(db), SkillTenantCfgs: NewSQLiteSkillTenantConfigStore(db), + SkillEvolution: NewSQLiteSkillEvolutionStore(db), SystemConfigs: NewSQLiteSystemConfigStore(db), Snapshots: NewSQLiteSnapshotStore(db), Cron: NewSQLiteCronStore(db), diff --git a/internal/store/sqlitestore/schema.go b/internal/store/sqlitestore/schema.go index e7791b79..8d165eb4 100644 --- a/internal/store/sqlitestore/schema.go +++ b/internal/store/sqlitestore/schema.go @@ -16,7 +16,7 @@ var schemaSQL string // SchemaVersion is the current SQLite schema version. // Bump this when adding new migration steps below. -const SchemaVersion = 46 +const SchemaVersion = 47 // migrations maps version → SQL to apply when upgrading FROM that version. // schema.sql always represents the LATEST full schema (for fresh DBs). @@ -831,8 +831,98 @@ CREATE TABLE IF NOT EXISTS secure_cli_agent_credentials ( CREATE INDEX IF NOT EXISTS idx_scac_tenant ON secure_cli_agent_credentials(tenant_id); CREATE INDEX IF NOT EXISTS idx_scac_binary ON secure_cli_agent_credentials(binary_id); CREATE INDEX IF NOT EXISTS idx_scac_agent ON secure_cli_agent_credentials(agent_id);`, + // Version 46 → 47: skill self-evolution settings, metrics, suggestions, and immutable version records. + 46: addSkillSelfEvolutionTables, } +const addSkillSelfEvolutionTables = ` +CREATE TABLE IF NOT EXISTS skill_evolution_settings ( + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + enabled INTEGER NOT NULL DEFAULT 0, + mode VARCHAR(32) NOT NULL DEFAULT 'suggest_only', + last_analyzed_at TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + PRIMARY KEY (tenant_id, skill_id), + CHECK (mode IN ('suggest_only', 'auto_analyze')) +); +CREATE INDEX IF NOT EXISTS idx_skill_evolution_settings_skill ON skill_evolution_settings(skill_id); + +CREATE TABLE IF NOT EXISTS skill_usage_metrics ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + skill_slug VARCHAR(255) NOT NULL, + skill_version INTEGER NOT NULL DEFAULT 1, + agent_id TEXT, + user_id VARCHAR(255), + session_key TEXT, + trace_id TEXT, + invocation_id TEXT, + invocation_source VARCHAR(32) NOT NULL DEFAULT 'runtime', + status VARCHAR(32) NOT NULL DEFAULT 'started', + failure_reason TEXT, + tool_calls_count INTEGER NOT NULL DEFAULT 0, + duration_ms INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + CHECK (status IN ('started', 'succeeded', 'failed', 'abandoned')) +); +CREATE INDEX IF NOT EXISTS idx_skill_usage_metrics_skill_created ON skill_usage_metrics(skill_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_skill_usage_metrics_tenant_created ON skill_usage_metrics(tenant_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_skill_usage_metrics_status ON skill_usage_metrics(skill_id, status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_skill_usage_metrics_invocation ON skill_usage_metrics(invocation_id); + +CREATE TABLE IF NOT EXISTS skill_improvement_suggestions ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + skill_slug VARCHAR(255) NOT NULL, + suggestion_type VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'pending', + reason TEXT NOT NULL DEFAULT '', + evidence TEXT NOT NULL DEFAULT '{}', + draft_patch TEXT NOT NULL DEFAULT '{}', + target_file TEXT NOT NULL DEFAULT '', + created_by_actor_type VARCHAR(32) NOT NULL DEFAULT '', + created_by_actor_id VARCHAR(255) NOT NULL DEFAULT '', + reviewed_by_actor_type VARCHAR(32) NOT NULL DEFAULT '', + reviewed_by_actor_id VARCHAR(255) NOT NULL DEFAULT '', + reviewed_at TEXT, + applied_version INTEGER, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + CHECK (status IN ('pending', 'approved', 'rejected', 'applied')) +); +CREATE INDEX IF NOT EXISTS idx_skill_suggestions_skill_status_created ON skill_improvement_suggestions(skill_id, status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_skill_suggestions_tenant_created ON skill_improvement_suggestions(tenant_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS skill_versions ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + version INTEGER NOT NULL, + content_hash VARCHAR(64) NOT NULL DEFAULT '', + changed_files TEXT NOT NULL DEFAULT '[]', + created_by_actor_type VARCHAR(32) NOT NULL DEFAULT '', + created_by_actor_id VARCHAR(255) NOT NULL DEFAULT '', + created_from_suggestion_id TEXT REFERENCES skill_improvement_suggestions(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(skill_id, version) +); +CREATE INDEX IF NOT EXISTS idx_skill_versions_tenant_skill ON skill_versions(tenant_id, skill_id, version DESC); + +INSERT OR IGNORE INTO skill_versions ( + id, tenant_id, skill_id, version, content_hash, changed_files, + created_by_actor_type, created_by_actor_id, created_at +) +SELECT lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-' || lower(hex(randomblob(2))) || '-' || + lower(hex(randomblob(2))) || '-' || lower(hex(randomblob(6))), + tenant_id, id, version, COALESCE(file_hash, ''), '[]', + 'system', 'migration', COALESCE(created_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +FROM skills +WHERE status != 'deleted';` + const addChannelMemoryExtractionTables = ` CREATE TABLE IF NOT EXISTS channel_memory_extraction_runs ( id TEXT NOT NULL PRIMARY KEY, diff --git a/internal/store/sqlitestore/schema.sql b/internal/store/sqlitestore/schema.sql index 28989c07..10e72532 100644 --- a/internal/store/sqlitestore/schema.sql +++ b/internal/store/sqlitestore/schema.sql @@ -2102,3 +2102,83 @@ CREATE INDEX IF NOT EXISTS idx_browser_cookies_scope_domain ON browser_cookies (tenant_id, user_id, agent_id, domain); CREATE INDEX IF NOT EXISTS idx_browser_cookies_expires_at ON browser_cookies (expires_at); + +-- ============================================================ +-- Skill self-evolution (migration 000078 / SQLite schema 47) +-- ============================================================ + +CREATE TABLE IF NOT EXISTS skill_evolution_settings ( + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + enabled INTEGER NOT NULL DEFAULT 0, + mode VARCHAR(32) NOT NULL DEFAULT 'suggest_only', + last_analyzed_at TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + PRIMARY KEY (tenant_id, skill_id), + CHECK (mode IN ('suggest_only', 'auto_analyze')) +); +CREATE INDEX IF NOT EXISTS idx_skill_evolution_settings_skill ON skill_evolution_settings(skill_id); + +CREATE TABLE IF NOT EXISTS skill_usage_metrics ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + skill_slug VARCHAR(255) NOT NULL, + skill_version INTEGER NOT NULL DEFAULT 1, + agent_id TEXT, + user_id VARCHAR(255), + session_key TEXT, + trace_id TEXT, + invocation_id TEXT, + invocation_source VARCHAR(32) NOT NULL DEFAULT 'runtime', + status VARCHAR(32) NOT NULL DEFAULT 'started', + failure_reason TEXT, + tool_calls_count INTEGER NOT NULL DEFAULT 0, + duration_ms INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + CHECK (status IN ('started', 'succeeded', 'failed', 'abandoned')) +); +CREATE INDEX IF NOT EXISTS idx_skill_usage_metrics_skill_created ON skill_usage_metrics(skill_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_skill_usage_metrics_tenant_created ON skill_usage_metrics(tenant_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_skill_usage_metrics_status ON skill_usage_metrics(skill_id, status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_skill_usage_metrics_invocation ON skill_usage_metrics(invocation_id); + +CREATE TABLE IF NOT EXISTS skill_improvement_suggestions ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + skill_slug VARCHAR(255) NOT NULL, + suggestion_type VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'pending', + reason TEXT NOT NULL DEFAULT '', + evidence TEXT NOT NULL DEFAULT '{}', + draft_patch TEXT NOT NULL DEFAULT '{}', + target_file TEXT NOT NULL DEFAULT '', + created_by_actor_type VARCHAR(32) NOT NULL DEFAULT '', + created_by_actor_id VARCHAR(255) NOT NULL DEFAULT '', + reviewed_by_actor_type VARCHAR(32) NOT NULL DEFAULT '', + reviewed_by_actor_id VARCHAR(255) NOT NULL DEFAULT '', + reviewed_at TEXT, + applied_version INTEGER, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + CHECK (status IN ('pending', 'approved', 'rejected', 'applied')) +); +CREATE INDEX IF NOT EXISTS idx_skill_suggestions_skill_status_created ON skill_improvement_suggestions(skill_id, status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_skill_suggestions_tenant_created ON skill_improvement_suggestions(tenant_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS skill_versions ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + version INTEGER NOT NULL, + content_hash VARCHAR(64) NOT NULL DEFAULT '', + changed_files TEXT NOT NULL DEFAULT '[]', + created_by_actor_type VARCHAR(32) NOT NULL DEFAULT '', + created_by_actor_id VARCHAR(255) NOT NULL DEFAULT '', + created_from_suggestion_id TEXT REFERENCES skill_improvement_suggestions(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(skill_id, version) +); +CREATE INDEX IF NOT EXISTS idx_skill_versions_tenant_skill ON skill_versions(tenant_id, skill_id, version DESC); diff --git a/internal/store/sqlitestore/skill_evolution.go b/internal/store/sqlitestore/skill_evolution.go new file mode 100644 index 00000000..285b9555 --- /dev/null +++ b/internal/store/sqlitestore/skill_evolution.go @@ -0,0 +1,505 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +type SQLiteSkillEvolutionStore struct { + db *sql.DB +} + +func NewSQLiteSkillEvolutionStore(db *sql.DB) *SQLiteSkillEvolutionStore { + return &SQLiteSkillEvolutionStore{db: db} +} + +func (s *SQLiteSkillEvolutionStore) resolveSkill(ctx context.Context, skillID uuid.UUID) (string, int, error) { + tenantID := tenantIDForInsert(ctx) + var slug, skillTenant string + var version int + var isSystem bool + err := s.db.QueryRowContext(ctx, + `SELECT slug, version, tenant_id, is_system FROM skills WHERE id = ? AND status != 'deleted'`, + skillID.String(), + ).Scan(&slug, &version, &skillTenant, &isSystem) + if errors.Is(err, sql.ErrNoRows) { + return "", 0, fmt.Errorf("skill not found") + } + if err != nil { + return "", 0, err + } + if !store.IsCrossTenant(ctx) && !isSystem && skillTenant != tenantID.String() { + return "", 0, fmt.Errorf("skill not found") + } + return slug, version, nil +} + +func (s *SQLiteSkillEvolutionStore) GetSettings(ctx context.Context, skillID uuid.UUID) (*store.SkillEvolutionSettings, error) { + if _, _, err := s.resolveSkill(ctx, skillID); err != nil { + return nil, err + } + tenantID := tenantIDForInsert(ctx) + var out store.SkillEvolutionSettings + var tenantStr, skillStr string + var last nullSqliteTime + var created, updated sqliteTime + err := s.db.QueryRowContext(ctx, + `SELECT tenant_id, skill_id, enabled, mode, last_analyzed_at, created_at, updated_at + FROM skill_evolution_settings WHERE tenant_id = ? AND skill_id = ?`, + tenantID.String(), skillID.String(), + ).Scan(&tenantStr, &skillStr, &out.Enabled, &out.Mode, &last, &created, &updated) + if errors.Is(err, sql.ErrNoRows) { + return &store.SkillEvolutionSettings{ + TenantID: tenantID, + SkillID: skillID, + Enabled: false, + Mode: store.SkillEvolutionModeSuggestOnly, + }, nil + } + if err != nil { + return nil, err + } + out.TenantID, _ = uuid.Parse(tenantStr) + out.SkillID, _ = uuid.Parse(skillStr) + if last.Valid { + t := last.Time + out.LastAnalyzedAt = &t + } + out.CreatedAt = created.Time + out.UpdatedAt = updated.Time + return &out, nil +} + +func (s *SQLiteSkillEvolutionStore) UpsertSettings(ctx context.Context, settings store.SkillEvolutionSettings) (*store.SkillEvolutionSettings, error) { + if _, _, err := s.resolveSkill(ctx, settings.SkillID); err != nil { + return nil, err + } + tenantID := tenantIDForInsert(ctx) + mode := strings.TrimSpace(settings.Mode) + if mode == "" { + mode = store.SkillEvolutionModeSuggestOnly + } + now := time.Now().UTC().Format(time.RFC3339Nano) + var last any + if settings.LastAnalyzedAt != nil { + last = settings.LastAnalyzedAt.UTC().Format(time.RFC3339Nano) + } + _, err := s.db.ExecContext(ctx, + `INSERT INTO skill_evolution_settings (tenant_id, skill_id, enabled, mode, last_analyzed_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(tenant_id, skill_id) DO UPDATE SET + enabled = excluded.enabled, + mode = excluded.mode, + last_analyzed_at = excluded.last_analyzed_at, + updated_at = excluded.updated_at`, + tenantID.String(), settings.SkillID.String(), settings.Enabled, mode, last, now, now, + ) + if err != nil { + return nil, err + } + return s.GetSettings(ctx, settings.SkillID) +} + +func (s *SQLiteSkillEvolutionStore) RecordUsage(ctx context.Context, metric store.SkillUsageMetric) error { + slug, version, err := s.resolveSkill(ctx, metric.SkillID) + if err != nil { + return err + } + tenantID := tenantIDForInsert(ctx) + if metric.ID == uuid.Nil { + metric.ID = uuid.New() + } + if metric.SkillSlug == "" { + metric.SkillSlug = slug + } + if metric.SkillVersion == 0 { + metric.SkillVersion = version + } + if metric.Status == "" { + metric.Status = store.SkillUsageStatusStarted + } + agentID := "" + if metric.AgentID != uuid.Nil { + agentID = metric.AgentID.String() + } + _, err = s.db.ExecContext(ctx, + `INSERT INTO skill_usage_metrics + (id, tenant_id, skill_id, skill_slug, skill_version, agent_id, user_id, session_key, + trace_id, invocation_id, invocation_source, status, failure_reason, tool_calls_count, duration_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + metric.ID.String(), tenantID.String(), metric.SkillID.String(), metric.SkillSlug, metric.SkillVersion, + agentID, metric.UserID, metric.SessionKey, metric.TraceID, metric.InvocationID, metric.InvocationSource, + metric.Status, metric.FailureReason, metric.ToolCallsCount, metric.DurationMs, + ) + return err +} + +func (s *SQLiteSkillEvolutionStore) AggregateUsage(ctx context.Context, skillID uuid.UUID, since *time.Time) (*store.SkillUsageStats, error) { + if _, _, err := s.resolveSkill(ctx, skillID); err != nil { + return nil, err + } + tenantID := tenantIDForInsert(ctx) + where := "tenant_id = ? AND skill_id = ?" + args := []any{tenantID.String(), skillID.String()} + if since != nil { + where += " AND created_at >= ?" + args = append(args, since.UTC().Format(time.RFC3339Nano)) + } + query := fmt.Sprintf( + `SELECT COUNT(*), + SUM(CASE WHEN status = 'started' THEN 1 ELSE 0 END), + SUM(CASE WHEN status = 'succeeded' THEN 1 ELSE 0 END), + SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), + SUM(CASE WHEN status = 'abandoned' THEN 1 ELSE 0 END), + MAX(created_at) + FROM skill_usage_metrics WHERE %s`, + where, + ) + var out store.SkillUsageStats + out.SkillID = skillID + var last nullSqliteTime + if err := s.db.QueryRowContext(ctx, query, args...).Scan(&out.TotalCalls, &out.Started, &out.Succeeded, &out.Failed, &out.Abandoned, &last); err != nil { + return nil, err + } + if last.Valid { + t := last.Time + out.LastUsedAt = &t + } + if out.TotalCalls > 0 { + out.SuccessRate = float64(out.Succeeded) / float64(out.TotalCalls) + out.FailureRate = float64(out.Failed) / float64(out.TotalCalls) + } + reasonQuery := fmt.Sprintf( + `SELECT failure_reason, COUNT(*), MAX(created_at) + FROM skill_usage_metrics + WHERE %s AND status = 'failed' AND COALESCE(failure_reason, '') != '' + GROUP BY failure_reason ORDER BY COUNT(*) DESC, MAX(created_at) DESC LIMIT 5`, + where, + ) + rows, err := s.db.QueryContext(ctx, reasonQuery, args...) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var r store.SkillFailureReason + var lastSeen sqliteTime + if err := rows.Scan(&r.Reason, &r.Count, &lastSeen); err != nil { + return nil, err + } + r.LastSeen = lastSeen.Time + out.TopFailureReasons = append(out.TopFailureReasons, r) + } + return &out, rows.Err() +} + +func (s *SQLiteSkillEvolutionStore) ListUsage(ctx context.Context, skillID uuid.UUID, limit int) ([]store.SkillUsageMetric, error) { + if _, _, err := s.resolveSkill(ctx, skillID); err != nil { + return nil, err + } + if limit <= 0 { + limit = 50 + } + tenantID := tenantIDForInsert(ctx) + rows, err := s.db.QueryContext(ctx, + `SELECT id, tenant_id, skill_id, skill_slug, skill_version, COALESCE(agent_id, ''), + COALESCE(user_id,''), COALESCE(session_key,''), COALESCE(trace_id,''), COALESCE(invocation_id,''), + invocation_source, status, COALESCE(failure_reason,''), tool_calls_count, duration_ms, created_at + FROM skill_usage_metrics + WHERE tenant_id = ? AND skill_id = ? + ORDER BY created_at DESC LIMIT ?`, + tenantID.String(), skillID.String(), limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []store.SkillUsageMetric + for rows.Next() { + var m store.SkillUsageMetric + var idStr, tenantStr, skillStr, agentStr string + var created sqliteTime + if err := rows.Scan(&idStr, &tenantStr, &skillStr, &m.SkillSlug, &m.SkillVersion, &agentStr, + &m.UserID, &m.SessionKey, &m.TraceID, &m.InvocationID, &m.InvocationSource, &m.Status, + &m.FailureReason, &m.ToolCallsCount, &m.DurationMs, &created); err != nil { + return nil, err + } + m.ID, _ = uuid.Parse(idStr) + m.TenantID, _ = uuid.Parse(tenantStr) + m.SkillID, _ = uuid.Parse(skillStr) + if agentStr != "" { + m.AgentID, _ = uuid.Parse(agentStr) + } + m.CreatedAt = created.Time + out = append(out, m) + } + return out, rows.Err() +} + +func (s *SQLiteSkillEvolutionStore) CreateSuggestion(ctx context.Context, sg store.SkillImprovementSuggestion) (*store.SkillImprovementSuggestion, error) { + slug, _, err := s.resolveSkill(ctx, sg.SkillID) + if err != nil { + return nil, err + } + if sg.ID == uuid.Nil { + sg.ID = uuid.New() + } + if sg.SkillSlug == "" { + sg.SkillSlug = slug + } + if sg.Status == "" { + sg.Status = store.SkillSuggestionStatusPending + } + tenantID := tenantIDForInsert(ctx) + now := time.Now().UTC().Format(time.RFC3339Nano) + _, err = s.db.ExecContext(ctx, + `INSERT INTO skill_improvement_suggestions + (id, tenant_id, skill_id, skill_slug, suggestion_type, status, reason, evidence, + draft_patch, target_file, created_by_actor_type, created_by_actor_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + sg.ID.String(), tenantID.String(), sg.SkillID.String(), sg.SkillSlug, sg.SuggestionType, + sg.Status, sg.Reason, string(jsonOrDefault(sg.Evidence, "{}")), string(jsonOrDefault(sg.DraftPatch, "{}")), + sg.TargetFile, sg.CreatedByActorType, sg.CreatedByActorID, now, now, + ) + if err != nil { + return nil, err + } + return s.GetSuggestion(ctx, sg.ID) +} + +func (s *SQLiteSkillEvolutionStore) ListSuggestions(ctx context.Context, skillID uuid.UUID, status string, limit int) ([]store.SkillImprovementSuggestion, error) { + if _, _, err := s.resolveSkill(ctx, skillID); err != nil { + return nil, err + } + if limit <= 0 { + limit = 50 + } + tenantID := tenantIDForInsert(ctx) + q := `SELECT id, tenant_id, skill_id, skill_slug, suggestion_type, status, reason, evidence, draft_patch, + COALESCE(target_file,''), COALESCE(created_by_actor_type,''), COALESCE(created_by_actor_id,''), + COALESCE(reviewed_by_actor_type,''), COALESCE(reviewed_by_actor_id,''), reviewed_at, applied_version, + created_at, updated_at + FROM skill_improvement_suggestions WHERE tenant_id = ? AND skill_id = ?` + args := []any{tenantID.String(), skillID.String()} + if status != "" { + q += " AND status = ?" + args = append(args, status) + } + q += " ORDER BY created_at DESC LIMIT ?" + args = append(args, limit) + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + return scanSQLiteSuggestions(rows) +} + +func (s *SQLiteSkillEvolutionStore) GetSuggestion(ctx context.Context, id uuid.UUID) (*store.SkillImprovementSuggestion, error) { + tenantID := tenantIDForInsert(ctx) + rows, err := s.db.QueryContext(ctx, + `SELECT id, tenant_id, skill_id, skill_slug, suggestion_type, status, reason, evidence, draft_patch, + COALESCE(target_file,''), COALESCE(created_by_actor_type,''), COALESCE(created_by_actor_id,''), + COALESCE(reviewed_by_actor_type,''), COALESCE(reviewed_by_actor_id,''), reviewed_at, applied_version, + created_at, updated_at + FROM skill_improvement_suggestions WHERE tenant_id = ? AND id = ?`, + tenantID.String(), id.String()) + if err != nil { + return nil, err + } + defer rows.Close() + items, err := scanSQLiteSuggestions(rows) + if err != nil || len(items) == 0 { + return nil, err + } + if _, _, err := s.resolveSkill(ctx, items[0].SkillID); err != nil { + return nil, err + } + return &items[0], nil +} + +func (s *SQLiteSkillEvolutionStore) UpdateSuggestionStatus(ctx context.Context, id uuid.UUID, status, actorType, actorID string) (*store.SkillImprovementSuggestion, error) { + tenantID := tenantIDForInsert(ctx) + now := time.Now().UTC().Format(time.RFC3339Nano) + res, err := s.db.ExecContext(ctx, + `UPDATE skill_improvement_suggestions + SET status = ?, reviewed_by_actor_type = ?, reviewed_by_actor_id = ?, reviewed_at = ?, updated_at = ? + WHERE tenant_id = ? AND id = ?`, + status, actorType, actorID, now, now, tenantID.String(), id.String()) + if err != nil { + return nil, err + } + if n, _ := res.RowsAffected(); n == 0 { + return nil, fmt.Errorf("suggestion not found") + } + return s.GetSuggestion(ctx, id) +} + +func (s *SQLiteSkillEvolutionStore) MarkSuggestionApplied(ctx context.Context, id uuid.UUID, version int, actorType, actorID string) (*store.SkillImprovementSuggestion, error) { + tenantID := tenantIDForInsert(ctx) + now := time.Now().UTC().Format(time.RFC3339Nano) + res, err := s.db.ExecContext(ctx, + `UPDATE skill_improvement_suggestions + SET status = 'applied', applied_version = ?, reviewed_by_actor_type = ?, + reviewed_by_actor_id = ?, reviewed_at = COALESCE(reviewed_at, ?), updated_at = ? + WHERE tenant_id = ? AND id = ?`, + version, actorType, actorID, now, now, tenantID.String(), id.String()) + if err != nil { + return nil, err + } + if n, _ := res.RowsAffected(); n == 0 { + return nil, fmt.Errorf("suggestion not found") + } + return s.GetSuggestion(ctx, id) +} + +func (s *SQLiteSkillEvolutionStore) CreateSkillVersion(ctx context.Context, v store.SkillVersion) (*store.SkillVersion, error) { + if _, _, err := s.resolveSkill(ctx, v.SkillID); err != nil { + return nil, err + } + if v.ID == uuid.Nil { + v.ID = uuid.New() + } + tenantID := tenantIDForInsert(ctx) + suggestionID := "" + if v.CreatedFromSuggestionID != nil { + suggestionID = v.CreatedFromSuggestionID.String() + } + now := time.Now().UTC().Format(time.RFC3339Nano) + _, err := s.db.ExecContext(ctx, + `INSERT INTO skill_versions + (id, tenant_id, skill_id, version, content_hash, changed_files, created_by_actor_type, + created_by_actor_id, created_from_suggestion_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULLIF(?, ''), ?)`, + v.ID.String(), tenantID.String(), v.SkillID.String(), v.Version, v.ContentHash, + string(jsonOrDefault(v.ChangedFiles, "[]")), v.CreatedByActorType, v.CreatedByActorID, suggestionID, now, + ) + if err != nil { + return nil, err + } + return s.GetSkillVersion(ctx, v.SkillID, v.Version) +} + +func (s *SQLiteSkillEvolutionStore) ListSkillVersions(ctx context.Context, skillID uuid.UUID, limit int) ([]store.SkillVersion, error) { + if _, _, err := s.resolveSkill(ctx, skillID); err != nil { + return nil, err + } + if limit <= 0 { + limit = 50 + } + tenantID := tenantIDForInsert(ctx) + rows, err := s.db.QueryContext(ctx, + `SELECT id, tenant_id, skill_id, version, content_hash, changed_files, + COALESCE(created_by_actor_type,''), COALESCE(created_by_actor_id,''), + created_from_suggestion_id, created_at + FROM skill_versions WHERE tenant_id = ? AND skill_id = ? + ORDER BY version DESC LIMIT ?`, + tenantID.String(), skillID.String(), limit) + if err != nil { + return nil, err + } + defer rows.Close() + return scanSQLiteSkillVersions(rows) +} + +func (s *SQLiteSkillEvolutionStore) GetSkillVersion(ctx context.Context, skillID uuid.UUID, version int) (*store.SkillVersion, error) { + if _, _, err := s.resolveSkill(ctx, skillID); err != nil { + return nil, err + } + tenantID := tenantIDForInsert(ctx) + rows, err := s.db.QueryContext(ctx, + `SELECT id, tenant_id, skill_id, version, content_hash, changed_files, + COALESCE(created_by_actor_type,''), COALESCE(created_by_actor_id,''), + created_from_suggestion_id, created_at + FROM skill_versions WHERE tenant_id = ? AND skill_id = ? AND version = ?`, + tenantID.String(), skillID.String(), version) + if err != nil { + return nil, err + } + defer rows.Close() + items, err := scanSQLiteSkillVersions(rows) + if err != nil || len(items) == 0 { + return nil, err + } + return &items[0], nil +} + +func scanSQLiteSuggestions(rows *sql.Rows) ([]store.SkillImprovementSuggestion, error) { + var out []store.SkillImprovementSuggestion + for rows.Next() { + var sg store.SkillImprovementSuggestion + var idStr, tenantStr, skillStr string + var evidence, draft []byte + var reviewedAt nullSqliteTime + var applied sql.NullInt64 + var created, updated sqliteTime + if err := rows.Scan(&idStr, &tenantStr, &skillStr, &sg.SkillSlug, &sg.SuggestionType, &sg.Status, + &sg.Reason, &evidence, &draft, &sg.TargetFile, &sg.CreatedByActorType, &sg.CreatedByActorID, + &sg.ReviewedByActorType, &sg.ReviewedByActorID, &reviewedAt, &applied, + &created, &updated); err != nil { + return nil, err + } + sg.ID, _ = uuid.Parse(idStr) + sg.TenantID, _ = uuid.Parse(tenantStr) + sg.SkillID, _ = uuid.Parse(skillStr) + sg.Evidence = evidence + sg.DraftPatch = draft + if reviewedAt.Valid { + t := reviewedAt.Time + sg.ReviewedAt = &t + } + if applied.Valid { + v := int(applied.Int64) + sg.AppliedVersion = &v + } + sg.CreatedAt = created.Time + sg.UpdatedAt = updated.Time + out = append(out, sg) + } + return out, rows.Err() +} + +func scanSQLiteSkillVersions(rows *sql.Rows) ([]store.SkillVersion, error) { + var out []store.SkillVersion + for rows.Next() { + var v store.SkillVersion + var idStr, tenantStr, skillStr string + var changed []byte + var suggestion sql.NullString + var created sqliteTime + if err := rows.Scan(&idStr, &tenantStr, &skillStr, &v.Version, &v.ContentHash, &changed, + &v.CreatedByActorType, &v.CreatedByActorID, &suggestion, &created); err != nil { + return nil, err + } + v.ID, _ = uuid.Parse(idStr) + v.TenantID, _ = uuid.Parse(tenantStr) + v.SkillID, _ = uuid.Parse(skillStr) + v.ChangedFiles = changed + if suggestion.Valid && suggestion.String != "" { + parsed, _ := uuid.Parse(suggestion.String) + v.CreatedFromSuggestionID = &parsed + } + v.CreatedAt = created.Time + out = append(out, v) + } + return out, rows.Err() +} + +func jsonOrDefault(raw json.RawMessage, fallback string) json.RawMessage { + if len(raw) == 0 { + return json.RawMessage(fallback) + } + return raw +} + +var _ store.SkillEvolutionStore = (*SQLiteSkillEvolutionStore)(nil) diff --git a/internal/store/stores.go b/internal/store/stores.go index 2b3b07b6..d908a725 100644 --- a/internal/store/stores.go +++ b/internal/store/stores.go @@ -35,6 +35,7 @@ type Stores struct { Tenants TenantStore BuiltinToolTenantCfgs BuiltinToolTenantConfigStore SkillTenantCfgs SkillTenantConfigStore + SkillEvolution SkillEvolutionStore SystemConfigs SystemConfigStore SubagentTasks SubagentTaskStore Vault VaultStore diff --git a/internal/tools/skill_manage.go b/internal/tools/skill_manage.go index 405669c9..2cc5cdfe 100644 --- a/internal/tools/skill_manage.go +++ b/internal/tools/skill_manage.go @@ -558,41 +558,7 @@ func parseManagedSkillFiles(raw any) ([]managedSkillFile, error) { } func validateManagedSkillFilePath(rawPath string) (string, error) { - if rawPath == "" { - return "", fmt.Errorf("invalid file path %q: empty path", rawPath) - } - if strings.ContainsRune(rawPath, 0x00) { - return "", fmt.Errorf("invalid file path %q: null byte", rawPath) - } - if len(rawPath) >= 2 && rawPath[1] == ':' { - return "", fmt.Errorf("invalid file path %q: windows drive paths are not allowed", rawPath) - } - normalized := strings.ReplaceAll(rawPath, "\\", "/") - if strings.HasPrefix(normalized, "/") { - return "", fmt.Errorf("invalid file path %q: absolute paths are not allowed", rawPath) - } - for part := range strings.SplitSeq(normalized, "/") { - switch part { - case "..": - return "", fmt.Errorf("invalid file path %q: parent traversal is not allowed", rawPath) - case ".git": - return "", fmt.Errorf("invalid file path %q: system artifact paths are not allowed", rawPath) - } - if strings.HasPrefix(part, ".") { - return "", fmt.Errorf("invalid file path %q: hidden files are not allowed", rawPath) - } - } - cleanPath := path.Clean(normalized) - if cleanPath == "." || cleanPath == "SKILL.md" || strings.EqualFold(cleanPath, "SKILL.md") { - return "", fmt.Errorf("invalid file path %q: SKILL.md must be provided via content or find/replace", rawPath) - } - if strings.HasPrefix(cleanPath, "../") || cleanPath == ".." || strings.HasPrefix(cleanPath, "/") { - return "", fmt.Errorf("invalid file path %q: path escapes skill root", rawPath) - } - if skills.IsSystemArtifact(cleanPath) { - return "", fmt.Errorf("invalid file path %q: system artifact paths are not allowed", rawPath) - } - return cleanPath, nil + return skills.ValidateSkillTargetPath(rawPath, false) } func collectExistingManagedSkillCompanionFiles(srcDir string) ([]managedSkillFile, error) { diff --git a/internal/upgrade/version.go b/internal/upgrade/version.go index 36f249f9..67b4ce6e 100644 --- a/internal/upgrade/version.go +++ b/internal/upgrade/version.go @@ -2,4 +2,4 @@ package upgrade // RequiredSchemaVersion is the schema migration version this binary requires. // Bump this whenever adding a new SQL migration file. -const RequiredSchemaVersion uint = 77 +const RequiredSchemaVersion uint = 78 diff --git a/migrations/000078_skill_self_evolution.down.sql b/migrations/000078_skill_self_evolution.down.sql new file mode 100644 index 00000000..e87b3103 --- /dev/null +++ b/migrations/000078_skill_self_evolution.down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS skill_versions; +DROP TABLE IF EXISTS skill_improvement_suggestions; +DROP TABLE IF EXISTS skill_usage_metrics; +DROP TABLE IF EXISTS skill_evolution_settings; diff --git a/migrations/000078_skill_self_evolution.up.sql b/migrations/000078_skill_self_evolution.up.sql new file mode 100644 index 00000000..8ea90862 --- /dev/null +++ b/migrations/000078_skill_self_evolution.up.sql @@ -0,0 +1,89 @@ +CREATE TABLE skill_evolution_settings ( + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + skill_id UUID NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + enabled BOOLEAN NOT NULL DEFAULT false, + mode VARCHAR(32) NOT NULL DEFAULT 'suggest_only', + last_analyzed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (tenant_id, skill_id), + CONSTRAINT chk_skill_evolution_mode CHECK (mode IN ('suggest_only', 'auto_analyze')) +); + +CREATE INDEX idx_skill_evolution_settings_skill ON skill_evolution_settings(skill_id); + +CREATE TABLE skill_usage_metrics ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + skill_id UUID NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + skill_slug VARCHAR(255) NOT NULL, + skill_version INT NOT NULL DEFAULT 1, + agent_id UUID REFERENCES agents(id) ON DELETE SET NULL, + user_id VARCHAR(255), + session_key TEXT, + trace_id TEXT, + invocation_id TEXT, + invocation_source VARCHAR(32) NOT NULL DEFAULT 'runtime', + status VARCHAR(32) NOT NULL DEFAULT 'started', + failure_reason TEXT, + tool_calls_count INT NOT NULL DEFAULT 0, + duration_ms BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_skill_usage_status CHECK (status IN ('started', 'succeeded', 'failed', 'abandoned')) +); + +CREATE INDEX idx_skill_usage_metrics_skill_created ON skill_usage_metrics(skill_id, created_at DESC); +CREATE INDEX idx_skill_usage_metrics_tenant_created ON skill_usage_metrics(tenant_id, created_at DESC); +CREATE INDEX idx_skill_usage_metrics_status ON skill_usage_metrics(skill_id, status, created_at DESC); +CREATE INDEX idx_skill_usage_metrics_invocation ON skill_usage_metrics(invocation_id) WHERE invocation_id IS NOT NULL; + +CREATE TABLE skill_improvement_suggestions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + skill_id UUID NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + skill_slug VARCHAR(255) NOT NULL, + suggestion_type VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'pending', + reason TEXT NOT NULL DEFAULT '', + evidence JSONB NOT NULL DEFAULT '{}', + draft_patch JSONB NOT NULL DEFAULT '{}', + target_file TEXT NOT NULL DEFAULT '', + created_by_actor_type VARCHAR(32) NOT NULL DEFAULT '', + created_by_actor_id VARCHAR(255) NOT NULL DEFAULT '', + reviewed_by_actor_type VARCHAR(32) NOT NULL DEFAULT '', + reviewed_by_actor_id VARCHAR(255) NOT NULL DEFAULT '', + reviewed_at TIMESTAMPTZ, + applied_version INT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_skill_suggestion_status CHECK (status IN ('pending', 'approved', 'rejected', 'applied')) +); + +CREATE INDEX idx_skill_suggestions_skill_status_created ON skill_improvement_suggestions(skill_id, status, created_at DESC); +CREATE INDEX idx_skill_suggestions_tenant_created ON skill_improvement_suggestions(tenant_id, created_at DESC); + +CREATE TABLE skill_versions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + skill_id UUID NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + version INT NOT NULL, + content_hash VARCHAR(64) NOT NULL DEFAULT '', + changed_files JSONB NOT NULL DEFAULT '[]', + created_by_actor_type VARCHAR(32) NOT NULL DEFAULT '', + created_by_actor_id VARCHAR(255) NOT NULL DEFAULT '', + created_from_suggestion_id UUID REFERENCES skill_improvement_suggestions(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(skill_id, version) +); + +CREATE INDEX idx_skill_versions_tenant_skill ON skill_versions(tenant_id, skill_id, version DESC); + +INSERT INTO skill_versions ( + tenant_id, skill_id, version, content_hash, changed_files, + created_by_actor_type, created_by_actor_id, created_at +) +SELECT tenant_id, id, version, COALESCE(file_hash, ''), '[]'::jsonb, + 'system', 'migration', COALESCE(created_at, NOW()) +FROM skills +WHERE status != 'deleted' +ON CONFLICT (skill_id, version) DO NOTHING; diff --git a/ui/web/src/i18n/locales/en/skills.json b/ui/web/src/i18n/locales/en/skills.json index 90daadab..0a7c5fb0 100644 --- a/ui/web/src/i18n/locales/en/skills.json +++ b/ui/web/src/i18n/locales/en/skills.json @@ -227,6 +227,31 @@ "copySuccess": "Deeplink copied", "copyFailed": "Failed to copy deeplink" }, + "evolution": { + "tab": "Evolution", + "title": "Self-evolution", + "enabled": "Enabled", + "disabled": "Disabled", + "status": "Status: {{status}}", + "unavailable": "Evolution data is unavailable for this skill.", + "modes": { + "suggest_only": "Suggest only", + "auto_analyze": "Auto analyze" + }, + "metrics": "Metrics", + "total": "Total", + "succeeded": "Succeeded", + "failed": "Failed", + "successRate": "Success rate", + "suggestions": "Suggestions", + "noSuggestions": "No suggestions.", + "noReason": "No reason recorded.", + "approve": "Approve", + "reject": "Reject", + "apply": "Apply", + "activity": "Activity", + "noActivity": "No recent activity." + }, "toast": { "updated": "Skill updated", "updateFailed": "Failed to update skill", diff --git a/ui/web/src/i18n/locales/vi/skills.json b/ui/web/src/i18n/locales/vi/skills.json index 918eb60f..cc871d6e 100644 --- a/ui/web/src/i18n/locales/vi/skills.json +++ b/ui/web/src/i18n/locales/vi/skills.json @@ -177,6 +177,31 @@ "copySuccess": "Đã copy deeplink", "copyFailed": "Không thể copy deeplink" }, + "evolution": { + "tab": "Tự tiến hóa", + "title": "Tự tiến hóa", + "enabled": "Bật", + "disabled": "Tắt", + "status": "Trạng thái: {{status}}", + "unavailable": "Không có dữ liệu tiến hóa cho skill này.", + "modes": { + "suggest_only": "Chỉ đề xuất", + "auto_analyze": "Tự phân tích" + }, + "metrics": "Chỉ số", + "total": "Tổng", + "succeeded": "Thành công", + "failed": "Thất bại", + "successRate": "Tỉ lệ thành công", + "suggestions": "Đề xuất", + "noSuggestions": "Chưa có đề xuất.", + "noReason": "Chưa ghi lý do.", + "approve": "Duyệt", + "reject": "Từ chối", + "apply": "Áp dụng", + "activity": "Hoạt động", + "noActivity": "Chưa có hoạt động gần đây." + }, "toast": { "updated": "Đã cập nhật skill", "updateFailed": "Không thể cập nhật skill", diff --git a/ui/web/src/i18n/locales/zh/skills.json b/ui/web/src/i18n/locales/zh/skills.json index a44236c9..5effbd72 100644 --- a/ui/web/src/i18n/locales/zh/skills.json +++ b/ui/web/src/i18n/locales/zh/skills.json @@ -177,6 +177,31 @@ "copySuccess": "已复制 Deeplink", "copyFailed": "复制 Deeplink 失败" }, + "evolution": { + "tab": "进化", + "title": "自进化", + "enabled": "启用", + "disabled": "禁用", + "status": "状态:{{status}}", + "unavailable": "此 Skill 没有可用的进化数据。", + "modes": { + "suggest_only": "仅建议", + "auto_analyze": "自动分析" + }, + "metrics": "指标", + "total": "总计", + "succeeded": "成功", + "failed": "失败", + "successRate": "成功率", + "suggestions": "建议", + "noSuggestions": "暂无建议。", + "noReason": "未记录原因。", + "approve": "批准", + "reject": "拒绝", + "apply": "应用", + "activity": "活动", + "noActivity": "暂无最近活动。" + }, "toast": { "updated": "技能已更新", "updateFailed": "更新技能失败", diff --git a/ui/web/src/lib/query-keys.ts b/ui/web/src/lib/query-keys.ts index 3109fbee..46470119 100644 --- a/ui/web/src/lib/query-keys.ts +++ b/ui/web/src/lib/query-keys.ts @@ -57,6 +57,10 @@ export const queryKeys = { skills: { all: ["skills"] as const, agentGrants: (agentId: string) => ["skills", "agent", agentId] as const, + evolution: (skillId: string) => ["skills", skillId, "evolution"] as const, + metrics: (skillId: string) => ["skills", skillId, "metrics"] as const, + suggestions: (skillId: string) => ["skills", skillId, "suggestions"] as const, + activity: (skillId: string) => ["skills", skillId, "activity"] as const, runtimes: ["skills", "runtimes"] as const, }, cron: { diff --git a/ui/web/src/pages/skills/hooks/use-skill-evolution.ts b/ui/web/src/pages/skills/hooks/use-skill-evolution.ts new file mode 100644 index 00000000..167870fc --- /dev/null +++ b/ui/web/src/pages/skills/hooks/use-skill-evolution.ts @@ -0,0 +1,91 @@ +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useHttp } from "@/hooks/use-ws"; +import { queryKeys } from "@/lib/query-keys"; +import type { + SkillActivityLog, + SkillEvolutionSettings, + SkillImprovementSuggestion, + SkillUsageStats, +} from "@/types/skill"; + +export function useSkillEvolution(skillId: string | undefined, enabled: boolean) { + const http = useHttp(); + const queryClient = useQueryClient(); + + const settings = useQuery({ + queryKey: skillId ? queryKeys.skills.evolution(skillId) : ["skills", "missing", "evolution"], + queryFn: () => http.get(`/v1/skills/${skillId}/evolution`), + enabled: enabled && !!skillId, + }); + + const metrics = useQuery({ + queryKey: skillId ? queryKeys.skills.metrics(skillId) : ["skills", "missing", "metrics"], + queryFn: () => http.get(`/v1/skills/${skillId}/metrics`), + enabled: enabled && !!skillId, + }); + + const suggestions = useQuery({ + queryKey: skillId ? queryKeys.skills.suggestions(skillId) : ["skills", "missing", "suggestions"], + queryFn: async () => { + const res = await http.get<{ suggestions: SkillImprovementSuggestion[] }>(`/v1/skills/${skillId}/evolution/suggestions`); + return res.suggestions ?? []; + }, + enabled: enabled && !!skillId, + }); + + const activity = useQuery({ + queryKey: skillId ? queryKeys.skills.activity(skillId) : ["skills", "missing", "activity"], + queryFn: async () => { + const res = await http.get<{ activity: SkillActivityLog[] }>(`/v1/skills/${skillId}/activity`); + return res.activity ?? []; + }, + enabled: enabled && !!skillId, + retry: false, + }); + + const invalidate = useCallback(async () => { + if (!skillId) return; + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.skills.evolution(skillId) }), + queryClient.invalidateQueries({ queryKey: queryKeys.skills.metrics(skillId) }), + queryClient.invalidateQueries({ queryKey: queryKeys.skills.suggestions(skillId) }), + queryClient.invalidateQueries({ queryKey: queryKeys.skills.activity(skillId) }), + ]); + }, [queryClient, skillId]); + + const updateSettings = useCallback(async (updates: Partial>) => { + if (!skillId) return; + await http.patch(`/v1/skills/${skillId}/evolution`, updates); + await invalidate(); + }, [http, invalidate, skillId]); + + const approveSuggestion = useCallback(async (id: string) => { + if (!skillId) return; + await http.post(`/v1/skills/${skillId}/evolution/suggestions/${id}/approve`, {}); + await invalidate(); + }, [http, invalidate, skillId]); + + const rejectSuggestion = useCallback(async (id: string) => { + if (!skillId) return; + await http.post(`/v1/skills/${skillId}/evolution/suggestions/${id}/reject`, {}); + await invalidate(); + }, [http, invalidate, skillId]); + + const applySuggestion = useCallback(async (id: string) => { + if (!skillId) return; + await http.post(`/v1/skills/${skillId}/evolution/suggestions/${id}/apply`, {}); + await invalidate(); + }, [http, invalidate, skillId]); + + return { + settings, + metrics, + suggestions, + activity, + updateSettings, + approveSuggestion, + rejectSuggestion, + applySuggestion, + }; +} diff --git a/ui/web/src/pages/skills/lib/skill-detail-deeplink.test.ts b/ui/web/src/pages/skills/lib/skill-detail-deeplink.test.ts index 27759b5a..d493c168 100644 --- a/ui/web/src/pages/skills/lib/skill-detail-deeplink.test.ts +++ b/ui/web/src/pages/skills/lib/skill-detail-deeplink.test.ts @@ -1,29 +1,20 @@ import { describe, expect, it } from "vitest"; -import { - parseSkillDetailVersionParam, - shouldLoadSkillDetailFile, -} from "./skill-detail-deeplink"; +import { normalizeSkillDetailTab, parseSkillDetailVersionParam } from "./skill-detail-deeplink"; -describe("skill detail deeplink helpers", () => { - it("parses valid version params", () => { - expect(parseSkillDetailVersionParam("1")).toBe(1); - expect(parseSkillDetailVersionParam("42")).toBe(42); +describe("skill detail deeplink", () => { + it("keeps evolution tab when the skill has an id", () => { + expect(normalizeSkillDetailTab("evolution", true, true)).toBe("evolution"); }); - it("rejects malformed version params", () => { - expect(parseSkillDetailVersionParam(null)).toBeNull(); - expect(parseSkillDetailVersionParam("")).toBeNull(); - expect(parseSkillDetailVersionParam("abc")).toBeNull(); - expect(parseSkillDetailVersionParam("1.5")).toBeNull(); + it("falls back from unavailable tabs to content", () => { + expect(normalizeSkillDetailTab("files", false, true)).toBe("content"); + expect(normalizeSkillDetailTab("evolution", true, false)).toBe("content"); + expect(normalizeSkillDetailTab("unknown", true, true)).toBe("content"); + }); + + it("parses positive integer versions only", () => { + expect(parseSkillDetailVersionParam("4")).toBe(4); expect(parseSkillDetailVersionParam("0")).toBeNull(); - expect(parseSkillDetailVersionParam("-1")).toBeNull(); - }); - - it("loads a deeplinked file only from the files tab when a file list exists", () => { - expect(shouldLoadSkillDetailFile("files", "scripts/run.py", 3, null)).toBe(true); - expect(shouldLoadSkillDetailFile("content", "scripts/run.py", 3, null)).toBe(false); - expect(shouldLoadSkillDetailFile("files", null, 3, null)).toBe(false); - expect(shouldLoadSkillDetailFile("files", "scripts/run.py", 0, null)).toBe(false); - expect(shouldLoadSkillDetailFile("files", "scripts/run.py", 3, "scripts/run.py")).toBe(false); + expect(parseSkillDetailVersionParam("bad")).toBeNull(); }); }); diff --git a/ui/web/src/pages/skills/lib/skill-detail-deeplink.ts b/ui/web/src/pages/skills/lib/skill-detail-deeplink.ts index 396b2da5..7e5c010f 100644 --- a/ui/web/src/pages/skills/lib/skill-detail-deeplink.ts +++ b/ui/web/src/pages/skills/lib/skill-detail-deeplink.ts @@ -5,6 +5,12 @@ export function parseSkillDetailVersionParam(value: string | null): number | nul return parsed; } +export function normalizeSkillDetailTab(value: string, hasFiles: boolean, hasEvolution: boolean): "content" | "files" | "evolution" { + if (value === "files" && hasFiles) return "files"; + if (value === "evolution" && hasEvolution) return "evolution"; + return "content"; +} + export function shouldLoadSkillDetailFile( detailTab: string, selectedFilePath: string | null, diff --git a/ui/web/src/pages/skills/lib/skills-page-state.test.ts b/ui/web/src/pages/skills/lib/skills-page-state.test.ts index 01d96697..b66beb18 100644 --- a/ui/web/src/pages/skills/lib/skills-page-state.test.ts +++ b/ui/web/src/pages/skills/lib/skills-page-state.test.ts @@ -18,14 +18,14 @@ describe("skills page state", () => { }); it("serializes non-default filters while preserving modal params", () => { - const params = new URLSearchParams("skill=abc&detailTab=files&file=SKILL.md"); + const params = new URLSearchParams("skill=abc&detailTab=evolution&file=SKILL.md"); const next = serializeSkillsPageState(params, { tab: "custom", q: "pdf", filter: "missing-deps", sort: "deps" }); expect(next.get("tab")).toBe("custom"); expect(next.get("q")).toBe("pdf"); expect(next.get("filter")).toBe("missing-deps"); expect(next.get("sort")).toBe("deps"); expect(next.get("skill")).toBe("abc"); - expect(next.get("detailTab")).toBe("files"); + expect(next.get("detailTab")).toBe("evolution"); expect(next.get("file")).toBe("SKILL.md"); }); diff --git a/ui/web/src/pages/skills/skill-detail-dialog.tsx b/ui/web/src/pages/skills/skill-detail-dialog.tsx index 5f35623f..eb4be92e 100644 --- a/ui/web/src/pages/skills/skill-detail-dialog.tsx +++ b/ui/web/src/pages/skills/skill-detail-dialog.tsx @@ -23,9 +23,10 @@ import { toast } from "@/stores/use-toast-store"; import type { SkillInfo, SkillFile, SkillVersions } from "@/types/skill"; import { buildTree } from "./skill-file-helpers"; import { FileBrowser } from "./skill-file-browser"; -import { parseSkillDetailVersionParam, shouldLoadSkillDetailFile } from "./lib/skill-detail-deeplink"; +import { normalizeSkillDetailTab, parseSkillDetailVersionParam, shouldLoadSkillDetailFile } from "./lib/skill-detail-deeplink"; import { getSkillAccessModeKey } from "./lib/skill-access-mode"; import type { SkillExportFormat } from "./lib/skill-export-download"; +import { SkillEvolutionPanel } from "./skill-evolution-panel"; interface SkillDetailDialogProps { skill: SkillInfo & { content: string }; @@ -60,6 +61,8 @@ export function SkillDetailDialog({ }: SkillDetailDialogProps) { const { t } = useTranslation("skills"); const hasFiles = !!skill.id; + const hasEvolution = !!skill.id; + const activeDetailTab = normalizeSkillDetailTab(detailTab, hasFiles, hasEvolution); const accessModeKey = getSkillAccessModeKey(skill.visibility); const accessModeLabel = accessModeKey === "unknown" ? t("accessMode.unknown", { value: skill.visibility || t("unknownOwner") }) @@ -137,7 +140,7 @@ export function SkillDetailDialog({ }, [selectedVersion, loadFiles]); useEffect(() => { - if (detailTab !== "files" || !hasFiles) return; + if (activeDetailTab !== "files" || !hasFiles) return; loadVersions(); const versionParam = parseSkillDetailVersionParam(selectedVersionParam); if (versionParam !== null && versionParam !== selectedVersion) { @@ -147,15 +150,15 @@ export function SkillDetailDialog({ if (selectedVersion == null && skill.version) { setSelectedVersion(skill.version); } - }, [detailTab, hasFiles, loadVersions, selectedVersion, selectedVersionParam, skill.version]); + }, [activeDetailTab, hasFiles, loadVersions, selectedVersion, selectedVersionParam, skill.version]); useEffect(() => { - if (!shouldLoadSkillDetailFile(detailTab, selectedFilePath, files.length, activePath)) return; + if (!shouldLoadSkillDetailFile(activeDetailTab, selectedFilePath, files.length, activePath)) return; loadFileContent(selectedFilePath); - }, [activePath, detailTab, files.length, loadFileContent, selectedFilePath]); + }, [activePath, activeDetailTab, files.length, loadFileContent, selectedFilePath]); const handleTabChange = (tab: string) => { - onStateChange({ detailTab: tab }); + onStateChange({ detailTab: tab, version: tab === "files" ? selectedVersionParam : null, file: tab === "files" ? activePath : null }); if (tab === "files" && hasFiles) { loadVersions(); if (files.length === 0 && !filesLoading) { @@ -184,10 +187,10 @@ export function SkillDetailDialog({ url.pathname = "/skills"; const next = new URLSearchParams(url.search); next.set("skill", skill.id || skill.slug || skill.name); - next.set("detailTab", detailTab === "files" ? "files" : "content"); - if (detailTab === "files" && selectedVersion != null) next.set("version", String(selectedVersion)); + next.set("detailTab", activeDetailTab); + if (activeDetailTab === "files" && selectedVersion != null) next.set("version", String(selectedVersion)); else next.delete("version"); - if (detailTab === "files" && activePath) next.set("file", activePath); + if (activeDetailTab === "files" && activePath) next.set("file", activePath); else next.delete("file"); url.search = next.toString(); @@ -290,10 +293,11 @@ export function SkillDetailDialog({ )} - + {t("detail.content")} {hasFiles && {t("detail.files")}} + {hasEvolution && {t("evolution.tab")}} @@ -331,6 +335,12 @@ export function SkillDetailDialog({ /> )} + + {hasEvolution && ( + + + + )} diff --git a/ui/web/src/pages/skills/skill-evolution-panel.tsx b/ui/web/src/pages/skills/skill-evolution-panel.tsx new file mode 100644 index 00000000..763656d2 --- /dev/null +++ b/ui/web/src/pages/skills/skill-evolution-panel.tsx @@ -0,0 +1,170 @@ +import { useTranslation } from "react-i18next"; +import { Check, X, Play, Loader2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { useSkillEvolution } from "./hooks/use-skill-evolution"; +import type { SkillInfo } from "@/types/skill"; + +interface SkillEvolutionPanelProps { + skill: SkillInfo; + active: boolean; +} + +export function SkillEvolutionPanel({ skill, active }: SkillEvolutionPanelProps) { + const { t } = useTranslation("skills"); + const { + settings, + metrics, + suggestions, + activity, + updateSettings, + approveSuggestion, + rejectSuggestion, + applySuggestion, + } = useSkillEvolution(skill.id, active); + + if (!skill.id) { + return ( +
+ {t("evolution.unavailable")} +
+ ); + } + + const data = settings.data; + const stats = metrics.data; + const pending = suggestions.data?.filter((item) => item.status === "pending") ?? []; + const isBusy = settings.isFetching || metrics.isFetching || suggestions.isFetching; + + return ( +
+
+
+
+
+

{t("evolution.title")}

+ {isBusy && } +
+

{t("evolution.status", { status: data?.enabled ? t("evolution.enabled") : t("evolution.disabled") })}

+
+
+
+ + updateSettings({ enabled })} + /> +
+ +
+
+
+ +
+
+

{t("evolution.metrics")}

+ {stats?.last_used_at && {new Date(stats.last_used_at).toLocaleString()}} +
+
+ + + + +
+
+ +
+
+

{t("evolution.suggestions")}

+ 0 ? "default" : "outline"}>{pending.length} +
+
+ {(suggestions.data ?? []).length === 0 ? ( +

{t("evolution.noSuggestions")}

+ ) : ( + suggestions.data?.map((item) => ( +
+
+
+
+ {item.status} + {item.suggestion_type} + {item.target_file && {item.target_file}} +
+

{item.reason || t("evolution.noReason")}

+
+
+ {item.status === "pending" && ( + <> + + + + )} + {item.status === "approved" && !skill.is_system && ( + + )} +
+
+
+ )) + )} +
+
+ + {!activity.isError && ( +
+

{t("evolution.activity")}

+
+ {(activity.data ?? []).length === 0 ? ( +

{t("evolution.noActivity")}

+ ) : ( + activity.data?.slice(0, 8).map((item) => ( +
+ {item.action} + {new Date(item.created_at).toLocaleString()} +
+ )) + )} +
+
+ )} +
+ ); +} + +function Metric({ label, value }: { label: string; value: number | string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/ui/web/src/types/skill.ts b/ui/web/src/types/skill.ts index 3eb93153..34ec1073 100644 --- a/ui/web/src/types/skill.ts +++ b/ui/web/src/types/skill.ts @@ -56,3 +56,54 @@ export interface SkillAgentGrant { granted_by: string; can_manage: boolean; } + +export interface SkillEvolutionSettings { + tenant_id: string; + skill_id: string; + enabled: boolean; + mode: "suggest_only" | "auto_analyze"; + last_analyzed_at?: string; +} + +export interface SkillFailureReason { + reason: string; + count: number; + last_seen: string; +} + +export interface SkillUsageStats { + skill_id: string; + total_calls: number; + started: number; + succeeded: number; + failed: number; + abandoned: number; + success_rate: number; + failure_rate: number; + last_used_at?: string; + top_failure_reasons?: SkillFailureReason[]; +} + +export interface SkillImprovementSuggestion { + id: string; + skill_id: string; + skill_slug: string; + suggestion_type: string; + status: "pending" | "approved" | "rejected" | "applied"; + reason: string; + target_file?: string; + applied_version?: number; + created_at: string; + updated_at: string; +} + +export interface SkillActivityLog { + id: string; + actor_type: string; + actor_id: string; + action: string; + entity_type?: string; + entity_id?: string; + details?: unknown; + created_at: string; +}