stagent 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/README.md +44 -31
  2. package/dist/cli.js +24 -0
  3. package/docs/.coverage-gaps.json +154 -24
  4. package/docs/.last-generated +1 -1
  5. package/docs/features/agent-intelligence.md +12 -2
  6. package/docs/features/chat.md +40 -5
  7. package/docs/features/cost-usage.md +1 -1
  8. package/docs/features/documents.md +5 -2
  9. package/docs/features/inbox-notifications.md +10 -2
  10. package/docs/features/keyboard-navigation.md +12 -3
  11. package/docs/features/provider-runtimes.md +16 -2
  12. package/docs/features/settings.md +2 -2
  13. package/docs/features/shared-components.md +7 -3
  14. package/docs/features/tables.md +3 -1
  15. package/docs/features/tool-permissions.md +6 -2
  16. package/docs/features/workflows.md +6 -2
  17. package/docs/getting-started.md +1 -1
  18. package/docs/index.md +1 -1
  19. package/docs/journeys/developer.md +25 -2
  20. package/docs/journeys/personal-use.md +12 -5
  21. package/docs/journeys/power-user.md +45 -14
  22. package/docs/journeys/work-use.md +17 -8
  23. package/docs/manifest.json +15 -15
  24. package/docs/superpowers/plans/2026-04-07-instance-bootstrap.md +2 -2
  25. package/docs/superpowers/plans/2026-04-14-chat-command-namespace-refactor.md +1390 -0
  26. package/docs/superpowers/plans/2026-04-14-chat-environment-integration.md +1561 -0
  27. package/docs/superpowers/plans/2026-04-14-chat-polish-bundle-v1.md +1219 -0
  28. package/docs/superpowers/plans/2026-04-14-chat-session-persistence-provider-closeout.md +399 -0
  29. package/next.config.mjs +1 -0
  30. package/package.json +3 -3
  31. package/src/app/api/chat/conversations/[id]/skills/__tests__/activate.test.ts +141 -0
  32. package/src/app/api/chat/conversations/[id]/skills/activate/route.ts +74 -0
  33. package/src/app/api/chat/conversations/[id]/skills/deactivate/route.ts +33 -0
  34. package/src/app/api/chat/export/route.ts +52 -0
  35. package/src/app/api/chat/files/search/route.ts +50 -0
  36. package/src/app/api/environment/rescan-if-stale/__tests__/route.test.ts +45 -0
  37. package/src/app/api/environment/rescan-if-stale/route.ts +23 -0
  38. package/src/app/api/environment/skills/route.ts +13 -0
  39. package/src/app/api/schedules/[id]/execute/route.ts +2 -2
  40. package/src/app/api/settings/chat/pins/route.ts +94 -0
  41. package/src/app/api/settings/chat/saved-searches/__tests__/route.test.ts +119 -0
  42. package/src/app/api/settings/chat/saved-searches/route.ts +79 -0
  43. package/src/app/api/settings/environment/route.ts +26 -0
  44. package/src/app/api/tasks/[id]/execute/route.ts +52 -12
  45. package/src/app/api/tasks/[id]/respond/route.ts +31 -15
  46. package/src/app/api/tasks/[id]/resume/route.ts +24 -3
  47. package/src/app/documents/page.tsx +4 -1
  48. package/src/app/settings/page.tsx +2 -0
  49. package/src/components/book/content-blocks.tsx +1 -1
  50. package/src/components/chat/__tests__/capability-banner.test.tsx +38 -0
  51. package/src/components/chat/__tests__/chat-session-provider.test.tsx +166 -1
  52. package/src/components/chat/__tests__/skill-row.test.tsx +91 -0
  53. package/src/components/chat/capability-banner.tsx +68 -0
  54. package/src/components/chat/chat-command-popover.tsx +668 -47
  55. package/src/components/chat/chat-input.tsx +103 -8
  56. package/src/components/chat/chat-message.tsx +12 -3
  57. package/src/components/chat/chat-session-provider.tsx +73 -3
  58. package/src/components/chat/chat-shell.tsx +62 -3
  59. package/src/components/chat/command-tab-bar.tsx +68 -0
  60. package/src/components/chat/conversation-template-picker.tsx +421 -0
  61. package/src/components/chat/help-dialog.tsx +39 -0
  62. package/src/components/chat/skill-composition-conflict-dialog.tsx +96 -0
  63. package/src/components/chat/skill-row.tsx +147 -0
  64. package/src/components/documents/document-browser.tsx +37 -19
  65. package/src/components/notifications/__tests__/permission-response-actions.test.tsx +70 -0
  66. package/src/components/notifications/permission-response-actions.tsx +155 -1
  67. package/src/components/playbook/playbook-detail-view.tsx +1 -1
  68. package/src/components/settings/environment-section.tsx +102 -0
  69. package/src/components/shared/__tests__/filter-hint.test.tsx +40 -0
  70. package/src/components/shared/__tests__/saved-searches-manager.test.tsx +147 -0
  71. package/src/components/shared/command-palette.tsx +262 -2
  72. package/src/components/shared/filter-hint.tsx +70 -0
  73. package/src/components/shared/filter-input.tsx +59 -0
  74. package/src/components/shared/saved-searches-manager.tsx +199 -0
  75. package/src/components/tasks/task-bento-grid.tsx +12 -2
  76. package/src/components/tasks/task-card.tsx +3 -0
  77. package/src/components/tasks/task-chip-bar.tsx +30 -1
  78. package/src/hooks/__tests__/use-chat-autocomplete-tabs.test.ts +47 -0
  79. package/src/hooks/__tests__/use-saved-searches.test.ts +70 -0
  80. package/src/hooks/use-active-skills.ts +110 -0
  81. package/src/hooks/use-chat-autocomplete.ts +120 -7
  82. package/src/hooks/use-enriched-skills.ts +19 -0
  83. package/src/hooks/use-pinned-entries.ts +104 -0
  84. package/src/hooks/use-recent-user-messages.ts +19 -0
  85. package/src/hooks/use-saved-searches.ts +142 -0
  86. package/src/lib/agents/__tests__/claude-agent-sdk-options.test.ts +56 -0
  87. package/src/lib/agents/__tests__/claude-agent.test.ts +17 -4
  88. package/src/lib/agents/__tests__/task-dispatch.test.ts +166 -0
  89. package/src/lib/agents/__tests__/tool-permissions.test.ts +60 -0
  90. package/src/lib/agents/claude-agent.ts +105 -46
  91. package/src/lib/agents/handoff/bus.ts +2 -2
  92. package/src/lib/agents/profiles/__tests__/list-fused-profiles.test.ts +110 -0
  93. package/src/lib/agents/profiles/__tests__/registry.test.ts +47 -0
  94. package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +30 -3
  95. package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +6 -2
  96. package/src/lib/agents/profiles/list-fused-profiles.ts +104 -0
  97. package/src/lib/agents/profiles/registry.ts +97 -22
  98. package/src/lib/agents/profiles/types.ts +7 -1
  99. package/src/lib/agents/router.ts +3 -6
  100. package/src/lib/agents/runtime/__tests__/catalog.test.ts +130 -0
  101. package/src/lib/agents/runtime/__tests__/execution-target.test.ts +183 -0
  102. package/src/lib/agents/runtime/anthropic-direct.ts +8 -0
  103. package/src/lib/agents/runtime/catalog.ts +121 -0
  104. package/src/lib/agents/runtime/claude-sdk.ts +32 -0
  105. package/src/lib/agents/runtime/execution-target.ts +456 -0
  106. package/src/lib/agents/runtime/index.ts +4 -0
  107. package/src/lib/agents/runtime/launch-failure.ts +101 -0
  108. package/src/lib/agents/runtime/openai-codex.ts +35 -0
  109. package/src/lib/agents/runtime/openai-direct.ts +8 -0
  110. package/src/lib/agents/task-dispatch.ts +220 -0
  111. package/src/lib/agents/tool-permissions.ts +16 -1
  112. package/src/lib/chat/__tests__/active-skill-injection.test.ts +261 -0
  113. package/src/lib/chat/__tests__/clean-filter-input.test.ts +68 -0
  114. package/src/lib/chat/__tests__/command-tabs.test.ts +68 -0
  115. package/src/lib/chat/__tests__/context-builder-files.test.ts +112 -0
  116. package/src/lib/chat/__tests__/dismissals.test.ts +65 -0
  117. package/src/lib/chat/__tests__/engine-sdk-options.test.ts +117 -0
  118. package/src/lib/chat/__tests__/skill-conflict.test.ts +35 -0
  119. package/src/lib/chat/__tests__/types.test.ts +28 -0
  120. package/src/lib/chat/active-skills.ts +31 -0
  121. package/src/lib/chat/clean-filter-input.ts +30 -0
  122. package/src/lib/chat/codex-engine.ts +30 -7
  123. package/src/lib/chat/command-tabs.ts +61 -0
  124. package/src/lib/chat/context-builder.ts +141 -1
  125. package/src/lib/chat/dismissals.ts +73 -0
  126. package/src/lib/chat/engine.ts +109 -15
  127. package/src/lib/chat/files/__tests__/search.test.ts +135 -0
  128. package/src/lib/chat/files/expand-mention.ts +76 -0
  129. package/src/lib/chat/files/search.ts +99 -0
  130. package/src/lib/chat/skill-composition.ts +210 -0
  131. package/src/lib/chat/skill-conflict.ts +105 -0
  132. package/src/lib/chat/stagent-tools.ts +6 -19
  133. package/src/lib/chat/stream-telemetry.ts +9 -4
  134. package/src/lib/chat/system-prompt.ts +22 -0
  135. package/src/lib/chat/tool-catalog.ts +33 -3
  136. package/src/lib/chat/tools/__tests__/profile-tools.test.ts +51 -0
  137. package/src/lib/chat/tools/__tests__/settings-tools.test.ts +294 -0
  138. package/src/lib/chat/tools/__tests__/skill-tools.test.ts +474 -0
  139. package/src/lib/chat/tools/__tests__/task-tools.test.ts +47 -0
  140. package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +134 -0
  141. package/src/lib/chat/tools/blueprint-tools.ts +190 -0
  142. package/src/lib/chat/tools/helpers.ts +2 -0
  143. package/src/lib/chat/tools/profile-tools.ts +120 -23
  144. package/src/lib/chat/tools/skill-tools.ts +183 -0
  145. package/src/lib/chat/tools/task-tools.ts +6 -2
  146. package/src/lib/chat/tools/workflow-tools.ts +61 -20
  147. package/src/lib/chat/types.ts +15 -0
  148. package/src/lib/constants/settings.ts +2 -0
  149. package/src/lib/data/clear.ts +2 -6
  150. package/src/lib/db/bootstrap.ts +17 -0
  151. package/src/lib/db/schema.ts +26 -0
  152. package/src/lib/environment/__tests__/auto-promote.test.ts +132 -0
  153. package/src/lib/environment/__tests__/list-skills-enriched.test.ts +55 -0
  154. package/src/lib/environment/__tests__/skill-enrichment.test.ts +129 -0
  155. package/src/lib/environment/__tests__/skill-recommendations.test.ts +87 -0
  156. package/src/lib/environment/data.ts +9 -0
  157. package/src/lib/environment/list-skills.ts +176 -0
  158. package/src/lib/environment/parsers/__tests__/skill.test.ts +54 -0
  159. package/src/lib/environment/parsers/skill.ts +26 -5
  160. package/src/lib/environment/profile-generator.ts +56 -2
  161. package/src/lib/environment/skill-enrichment.ts +106 -0
  162. package/src/lib/environment/skill-recommendations.ts +66 -0
  163. package/src/lib/filters/__tests__/parse.quoted.test.ts +40 -0
  164. package/src/lib/filters/__tests__/parse.test.ts +135 -0
  165. package/src/lib/filters/parse.ts +86 -0
  166. package/src/lib/instance/__tests__/detect.test.ts +1 -1
  167. package/src/lib/instance/__tests__/upgrade-poller.test.ts +50 -0
  168. package/src/lib/instance/fingerprint.ts +8 -10
  169. package/src/lib/instance/upgrade-poller.ts +53 -1
  170. package/src/lib/schedules/scheduler.ts +4 -4
  171. package/src/lib/utils/stagent-paths.ts +4 -0
  172. package/src/lib/workflows/blueprints/__tests__/render-prompt.test.ts +124 -0
  173. package/src/lib/workflows/blueprints/render-prompt.ts +71 -0
  174. package/src/lib/workflows/blueprints/types.ts +6 -0
  175. package/src/lib/workflows/engine.ts +5 -3
  176. package/src/test/setup.ts +10 -0
@@ -0,0 +1,190 @@
1
+ import { defineTool } from "../tool-registry";
2
+ import { z } from "zod";
3
+ import { ok, err, type ToolContext } from "./helpers";
4
+
5
+ export function blueprintTools(ctx: ToolContext) {
6
+ return [
7
+ defineTool(
8
+ "list_blueprints",
9
+ "List available workflow blueprints. Blueprints are reusable workflow templates with configurable variables. Use instantiate_blueprint to create a workflow from one.",
10
+ {
11
+ domain: z
12
+ .enum(["work", "personal"])
13
+ .optional()
14
+ .describe("Filter by domain"),
15
+ search: z
16
+ .string()
17
+ .optional()
18
+ .describe("Search in name, description, and tags"),
19
+ },
20
+ async (args) => {
21
+ try {
22
+ const { listBlueprints } = await import(
23
+ "@/lib/workflows/blueprints/registry"
24
+ );
25
+ let blueprints = listBlueprints();
26
+
27
+ if (args.domain) {
28
+ blueprints = blueprints.filter((b) => b.domain === args.domain);
29
+ }
30
+ if (args.search) {
31
+ const q = args.search.toLowerCase();
32
+ blueprints = blueprints.filter(
33
+ (b) =>
34
+ b.name.toLowerCase().includes(q) ||
35
+ b.description.toLowerCase().includes(q) ||
36
+ b.tags.some((t) => t.toLowerCase().includes(q))
37
+ );
38
+ }
39
+
40
+ return ok(
41
+ blueprints.map((b) => ({
42
+ id: b.id,
43
+ name: b.name,
44
+ description: b.description,
45
+ domain: b.domain,
46
+ pattern: b.pattern,
47
+ tags: b.tags,
48
+ difficulty: b.difficulty,
49
+ estimatedDuration: b.estimatedDuration,
50
+ isBuiltin: b.isBuiltin,
51
+ variableCount: b.variables.length,
52
+ stepCount: b.steps.length,
53
+ }))
54
+ );
55
+ } catch (e) {
56
+ return err(
57
+ e instanceof Error ? e.message : "Failed to list blueprints"
58
+ );
59
+ }
60
+ }
61
+ ),
62
+
63
+ defineTool(
64
+ "get_blueprint",
65
+ "Get full details of a workflow blueprint, including its variables and steps. Use this to understand what inputs are needed before calling instantiate_blueprint.",
66
+ {
67
+ blueprintId: z.string().describe("The blueprint ID to look up"),
68
+ },
69
+ async (args) => {
70
+ try {
71
+ const { getBlueprint } = await import(
72
+ "@/lib/workflows/blueprints/registry"
73
+ );
74
+ const blueprint = getBlueprint(args.blueprintId);
75
+ if (!blueprint)
76
+ return err(`Blueprint not found: ${args.blueprintId}`);
77
+ return ok(blueprint);
78
+ } catch (e) {
79
+ return err(
80
+ e instanceof Error ? e.message : "Failed to get blueprint"
81
+ );
82
+ }
83
+ }
84
+ ),
85
+
86
+ defineTool(
87
+ "instantiate_blueprint",
88
+ "Create a draft workflow from a blueprint by filling in its variables. The workflow is created in 'draft' status — use execute_workflow to run it. Call get_blueprint first to see required variables.",
89
+ {
90
+ blueprintId: z
91
+ .string()
92
+ .describe("The blueprint ID to instantiate"),
93
+ variables: z
94
+ .record(z.string(), z.unknown())
95
+ .describe(
96
+ "Key-value map of variable values. Keys are variable IDs from the blueprint. Required variables must be provided."
97
+ ),
98
+ projectId: z
99
+ .string()
100
+ .optional()
101
+ .describe(
102
+ "Project ID to attach the workflow to. Omit to use the active project."
103
+ ),
104
+ },
105
+ async (args) => {
106
+ try {
107
+ const { instantiateBlueprint } = await import(
108
+ "@/lib/workflows/blueprints/instantiator"
109
+ );
110
+ const effectiveProjectId =
111
+ args.projectId ?? ctx.projectId ?? undefined;
112
+
113
+ const result = await instantiateBlueprint(
114
+ args.blueprintId,
115
+ args.variables,
116
+ effectiveProjectId
117
+ );
118
+
119
+ ctx.onToolResult?.("instantiate_blueprint", result);
120
+ return ok({
121
+ workflowId: result.workflowId,
122
+ name: result.name,
123
+ stepsCount: result.stepsCount,
124
+ skippedSteps: result.skippedSteps,
125
+ status: "draft",
126
+ message:
127
+ "Workflow created from blueprint. Use execute_workflow to run it.",
128
+ });
129
+ } catch (e) {
130
+ return err(
131
+ e instanceof Error ? e.message : "Failed to instantiate blueprint"
132
+ );
133
+ }
134
+ }
135
+ ),
136
+
137
+ defineTool(
138
+ "create_blueprint",
139
+ "Create a custom workflow blueprint from YAML content. The YAML must include id, name, description, version, domain, tags, pattern, variables, and steps. Use get_blueprint on an existing blueprint to see the expected structure.",
140
+ {
141
+ yaml: z
142
+ .string()
143
+ .describe(
144
+ "Full blueprint YAML content. Must validate against the blueprint schema."
145
+ ),
146
+ },
147
+ async (args) => {
148
+ try {
149
+ const { createBlueprint } = await import(
150
+ "@/lib/workflows/blueprints/registry"
151
+ );
152
+ const blueprint = createBlueprint(args.yaml);
153
+ ctx.onToolResult?.("create_blueprint", blueprint);
154
+ return ok({
155
+ id: blueprint.id,
156
+ name: blueprint.name,
157
+ message: "Blueprint created successfully",
158
+ });
159
+ } catch (e) {
160
+ return err(
161
+ e instanceof Error ? e.message : "Failed to create blueprint"
162
+ );
163
+ }
164
+ }
165
+ ),
166
+
167
+ defineTool(
168
+ "delete_blueprint",
169
+ "Delete a custom workflow blueprint. Built-in blueprints cannot be deleted.",
170
+ {
171
+ blueprintId: z.string().describe("The blueprint ID to delete"),
172
+ },
173
+ async (args) => {
174
+ try {
175
+ const { deleteBlueprint } = await import(
176
+ "@/lib/workflows/blueprints/registry"
177
+ );
178
+ deleteBlueprint(args.blueprintId);
179
+ return ok({
180
+ message: `Blueprint "${args.blueprintId}" deleted`,
181
+ });
182
+ } catch (e) {
183
+ return err(
184
+ e instanceof Error ? e.message : "Failed to delete blueprint"
185
+ );
186
+ }
187
+ }
188
+ ),
189
+ ];
190
+ }
@@ -10,6 +10,8 @@ import type { SQLiteColumn } from "drizzle-orm/sqlite-core";
10
10
  /** Context passed to each tool factory — provides project scoping and entity callbacks. */
11
11
  export interface ToolContext {
12
12
  projectId?: string | null;
13
+ /** Absolute path to the active project's working directory. Used by profile tools to surface filesystem skills. */
14
+ projectDir?: string | null;
13
15
  onToolResult?: (toolName: string, result: unknown) => void;
14
16
  }
15
17
 
@@ -2,30 +2,42 @@ import { defineTool } from "../tool-registry";
2
2
  import { z } from "zod";
3
3
  import { ok, err, type ToolContext } from "./helpers";
4
4
 
5
- export function profileTools(_ctx: ToolContext) {
6
- return [
7
- defineTool(
8
- "list_profiles",
9
- "List all available agent profiles with their capabilities and compatible runtimes.",
10
- {},
11
- async () => {
12
- try {
13
- const { listProfiles } = await import("@/lib/agents/profiles/registry");
14
- const profiles = listProfiles();
15
- return ok(
16
- profiles.map((p) => ({
17
- id: p.id,
18
- name: p.name,
19
- description: p.description,
20
- domain: p.domain,
21
- tags: p.tags,
22
- }))
23
- );
24
- } catch (e) {
25
- return err(e instanceof Error ? e.message : "Failed to list profiles");
26
- }
5
+ /**
6
+ * Factory for the list_profiles tool, parameterized by projectDir so it can
7
+ * surface project filesystem skills alongside registry profiles via
8
+ * listFusedProfiles. See features/chat-claude-sdk-skills.md.
9
+ */
10
+ export function getListProfilesTool(projectDir: string | null) {
11
+ return defineTool(
12
+ "list_profiles",
13
+ "List all available agent profiles and filesystem skills with their capabilities and compatible runtimes.",
14
+ {},
15
+ async () => {
16
+ try {
17
+ const { listFusedProfiles } = await import(
18
+ "@/lib/agents/profiles/list-fused-profiles"
19
+ );
20
+ const profiles = await listFusedProfiles(projectDir);
21
+ return ok(
22
+ profiles.map((p) => ({
23
+ id: p.id,
24
+ name: p.name,
25
+ description: p.description,
26
+ domain: p.domain,
27
+ tags: p.tags,
28
+ origin: p.origin ?? "registry",
29
+ }))
30
+ );
31
+ } catch (e) {
32
+ return err(e instanceof Error ? e.message : "Failed to list profiles");
27
33
  }
28
- ),
34
+ }
35
+ );
36
+ }
37
+
38
+ export function profileTools(ctx: ToolContext) {
39
+ return [
40
+ getListProfilesTool(ctx.projectDir ?? null),
29
41
 
30
42
  defineTool(
31
43
  "get_profile",
@@ -44,5 +56,90 @@ export function profileTools(_ctx: ToolContext) {
44
56
  }
45
57
  }
46
58
  ),
59
+
60
+ defineTool(
61
+ "create_profile",
62
+ "Create a new agent profile with a configuration and system prompt (SKILL.md). The profile is saved to ~/.claude/skills/ and becomes immediately available. Use get_profile on an existing profile to see the expected config structure.",
63
+ {
64
+ config: z.object({
65
+ id: z.string().min(1).describe("Unique profile ID (kebab-case, e.g. 'my-analyst')"),
66
+ name: z.string().min(1).describe("Human-readable profile name"),
67
+ version: z.string().regex(/^\d+\.\d+\.\d+$/).describe("Semver version, e.g. '1.0.0'"),
68
+ domain: z.enum(["work", "personal"]).describe("Profile domain"),
69
+ tags: z.array(z.string()).describe("Searchable tags"),
70
+ maxTurns: z.number().positive().optional().describe("Max agent turns per task"),
71
+ outputFormat: z.string().optional().describe("Expected output format hint"),
72
+ author: z.string().optional().describe("Profile author"),
73
+ }).describe("Profile configuration object"),
74
+ skillMd: z.string().min(1).describe(
75
+ "The SKILL.md content — this is the system prompt that defines the agent's behavior, personality, and instructions. Markdown format."
76
+ ),
77
+ },
78
+ async (args) => {
79
+ try {
80
+ const { createProfile } = await import("@/lib/agents/profiles/registry");
81
+ createProfile(args.config, args.skillMd);
82
+ ctx.onToolResult?.("create_profile", { id: args.config.id, name: args.config.name });
83
+ return ok({
84
+ id: args.config.id,
85
+ name: args.config.name,
86
+ message: "Profile created successfully",
87
+ });
88
+ } catch (e) {
89
+ return err(e instanceof Error ? e.message : "Failed to create profile");
90
+ }
91
+ }
92
+ ),
93
+
94
+ defineTool(
95
+ "update_profile",
96
+ "Update an existing agent profile's configuration and/or system prompt. Built-in profiles cannot be modified — duplicate them first with create_profile.",
97
+ {
98
+ profileId: z.string().describe("The profile ID to update"),
99
+ config: z.object({
100
+ id: z.string().min(1),
101
+ name: z.string().min(1),
102
+ version: z.string().regex(/^\d+\.\d+\.\d+$/),
103
+ domain: z.enum(["work", "personal"]),
104
+ tags: z.array(z.string()),
105
+ maxTurns: z.number().positive().optional(),
106
+ outputFormat: z.string().optional(),
107
+ author: z.string().optional(),
108
+ }).describe("Full profile configuration (replaces existing)"),
109
+ skillMd: z.string().min(1).describe("Updated SKILL.md content"),
110
+ },
111
+ async (args) => {
112
+ try {
113
+ const { updateProfile } = await import("@/lib/agents/profiles/registry");
114
+ updateProfile(args.profileId, args.config, args.skillMd);
115
+ ctx.onToolResult?.("update_profile", { id: args.profileId });
116
+ return ok({
117
+ id: args.profileId,
118
+ message: "Profile updated successfully",
119
+ });
120
+ } catch (e) {
121
+ return err(e instanceof Error ? e.message : "Failed to update profile");
122
+ }
123
+ }
124
+ ),
125
+
126
+ defineTool(
127
+ "delete_profile",
128
+ "Delete a custom agent profile. Built-in profiles cannot be deleted.",
129
+ {
130
+ profileId: z.string().describe("The profile ID to delete"),
131
+ },
132
+ async (args) => {
133
+ try {
134
+ const { deleteProfile } = await import("@/lib/agents/profiles/registry");
135
+ deleteProfile(args.profileId);
136
+ return ok({
137
+ message: `Profile "${args.profileId}" deleted`,
138
+ });
139
+ } catch (e) {
140
+ return err(e instanceof Error ? e.message : "Failed to delete profile");
141
+ }
142
+ }
143
+ ),
47
144
  ];
48
145
  }
@@ -0,0 +1,183 @@
1
+ import { z } from "zod";
2
+ import { defineTool } from "../tool-registry";
3
+ import { ok, err, type ToolContext } from "./helpers";
4
+
5
+ /**
6
+ * Stagent MCP tools for conversation-scoped skill management.
7
+ *
8
+ * Primary consumer: Ollama — the HTTP chat-completion API has no native
9
+ * concept of skills, so Stagent takes over: activate a skill (persist to
10
+ * conversations.active_skill_id) → context builder injects its SKILL.md
11
+ * into Tier 0 of every subsequent turn.
12
+ *
13
+ * Secondary consumer: Claude and Codex runtimes may also call these tools
14
+ * for a programmatic skill-activation path alongside their native Skill
15
+ * handling. The tools themselves are runtime-agnostic — they just bind
16
+ * skill IDs to conversation rows.
17
+ *
18
+ * See `features/chat-ollama-native-skills.md`.
19
+ */
20
+
21
+ // `mergeActiveSkillIds` lives in `@/lib/chat/active-skills` so client code
22
+ // can import the pure helper without pulling this module's `db` import.
23
+ // Re-exported here for back-compat with existing callers (tests, etc.).
24
+ import { mergeActiveSkillIds } from "@/lib/chat/active-skills";
25
+ export { mergeActiveSkillIds };
26
+
27
+ export function skillTools(_ctx: ToolContext) {
28
+ return [
29
+ defineTool(
30
+ "list_skills",
31
+ "List all Stagent-discoverable skills across user (~/.claude, ~/.codex) and project (.claude, .agents) scopes. Returns id, name, tool persona, scope, and a short preview for each. Pass `enriched: true` for additional per-skill metadata (healthScore, syncStatus, linkedProfileId). Read-only.",
32
+ {
33
+ enriched: z
34
+ .boolean()
35
+ .optional()
36
+ .describe(
37
+ "When true, include healthScore ('healthy'|'stale'|'aging'|'unknown'), syncStatus ('synced'|'claude-only'|'codex-only'|'shared'), and linkedProfileId per skill."
38
+ ),
39
+ },
40
+ async (args) => {
41
+ try {
42
+ if (args.enriched) {
43
+ const { listSkillsEnriched } = await import("@/lib/environment/list-skills");
44
+ const skills = listSkillsEnriched();
45
+ return ok({
46
+ count: skills.length,
47
+ skills: skills.map((s) => ({
48
+ id: s.id,
49
+ name: s.name,
50
+ tool: s.tool,
51
+ scope: s.scope,
52
+ preview: s.preview,
53
+ sizeBytes: s.sizeBytes,
54
+ healthScore: s.healthScore,
55
+ syncStatus: s.syncStatus,
56
+ linkedProfileId: s.linkedProfileId,
57
+ })),
58
+ });
59
+ }
60
+ const { listSkills } = await import("@/lib/environment/list-skills");
61
+ const skills = listSkills();
62
+ return ok({
63
+ count: skills.length,
64
+ skills: skills.map((s) => ({
65
+ id: s.id,
66
+ name: s.name,
67
+ tool: s.tool,
68
+ scope: s.scope,
69
+ preview: s.preview,
70
+ sizeBytes: s.sizeBytes,
71
+ })),
72
+ });
73
+ } catch (e) {
74
+ return err(e instanceof Error ? e.message : "list_skills failed");
75
+ }
76
+ }
77
+ ),
78
+
79
+ defineTool(
80
+ "get_skill",
81
+ "Return the full SKILL.md content plus metadata for a single skill, identified by the id returned from list_skills. Use this to preview a skill before activating it.",
82
+ {
83
+ id: z
84
+ .string()
85
+ .describe("Opaque skill ID (from list_skills). Typically the relative path."),
86
+ },
87
+ async (args) => {
88
+ try {
89
+ const { getSkill } = await import("@/lib/environment/list-skills");
90
+ const skill = getSkill(args.id);
91
+ if (!skill) return err(`Skill not found: ${args.id}`);
92
+ return ok({
93
+ id: skill.id,
94
+ name: skill.name,
95
+ tool: skill.tool,
96
+ scope: skill.scope,
97
+ sizeBytes: skill.sizeBytes,
98
+ content: skill.content,
99
+ });
100
+ } catch (e) {
101
+ return err(e instanceof Error ? e.message : "get_skill failed");
102
+ }
103
+ }
104
+ ),
105
+
106
+ defineTool(
107
+ "activate_skill",
108
+ "Activate a skill on a conversation. While active, the skill's SKILL.md is injected into the system prompt on every subsequent turn. Default mode 'replace' clears any prior active skills and binds just this one. Pass mode='add' to compose multiple skills (gated by runtime — Ollama refuses; Claude/Codex/direct allow up to 3). Pass force=true to skip conflict warnings on add.",
109
+ {
110
+ conversationId: z.string().describe("ID of the conversation to bind the skill to."),
111
+ skillId: z.string().describe("Opaque skill ID from list_skills (typically the relative path)."),
112
+ mode: z
113
+ .enum(["replace", "add"])
114
+ .optional()
115
+ .default("replace")
116
+ .describe("'replace' (default) clears prior active skills; 'add' appends — runtime must support composition."),
117
+ force: z
118
+ .boolean()
119
+ .optional()
120
+ .default(false)
121
+ .describe("When mode='add', skip the conflict heuristic check and add anyway."),
122
+ },
123
+ async (args) => {
124
+ const { activateSkill } = await import("@/lib/chat/skill-composition");
125
+ const result = await activateSkill({
126
+ conversationId: args.conversationId,
127
+ skillId: args.skillId,
128
+ mode: args.mode,
129
+ force: args.force,
130
+ });
131
+
132
+ if (result.kind === "error") return err(result.message);
133
+
134
+ if (result.kind === "conflicts") {
135
+ return ok({
136
+ conversationId: args.conversationId,
137
+ requiresConfirmation: true,
138
+ conflicts: result.conflicts,
139
+ hint: "Re-call activate_skill with force=true to add anyway",
140
+ });
141
+ }
142
+
143
+ // kind === "ok"
144
+ if (result.note === "skill already active") {
145
+ return ok({
146
+ conversationId: args.conversationId,
147
+ activeSkillIds: result.activeSkillIds,
148
+ note: result.note,
149
+ });
150
+ }
151
+
152
+ return ok({
153
+ conversationId: args.conversationId,
154
+ activatedSkillId: result.activatedSkillId,
155
+ activeSkillIds: result.activeSkillIds,
156
+ skillName: result.skillName,
157
+ });
158
+ }
159
+ ),
160
+
161
+ defineTool(
162
+ "deactivate_skill",
163
+ "Clear the active skill on a conversation. After this call, subsequent turns will not include any Stagent-injected SKILL.md in the system prompt.",
164
+ {
165
+ conversationId: z
166
+ .string()
167
+ .describe("ID of the conversation to clear the active skill from."),
168
+ },
169
+ async (args) => {
170
+ const { deactivateSkill } = await import("@/lib/chat/skill-composition");
171
+ const result = await deactivateSkill({ conversationId: args.conversationId });
172
+
173
+ if (result.kind === "error") return err(result.message);
174
+
175
+ return ok({
176
+ conversationId: args.conversationId,
177
+ previousSkillId: result.previousSkillId,
178
+ activeSkillId: null,
179
+ });
180
+ }
181
+ ),
182
+ ];
183
+ }
@@ -324,7 +324,7 @@ export function taskTools(ctx: ToolContext) {
324
324
  );
325
325
  }
326
326
 
327
- const runtimeId = args.assignedAgent ?? task.assignedAgent ?? DEFAULT_AGENT_RUNTIME;
327
+ const runtimeId = args.assignedAgent ?? task.assignedAgent ?? null;
328
328
 
329
329
  // Set status to queued
330
330
  await db
@@ -337,7 +337,11 @@ export function taskTools(ctx: ToolContext) {
337
337
  executeTaskWithAgent(taskId, runtimeId).catch(() => {});
338
338
 
339
339
  ctx.onToolResult?.("execute_task", { id: taskId, title: task.title });
340
- return ok({ message: "Execution started", taskId, runtime: runtimeId });
340
+ return ok({
341
+ message: "Execution started",
342
+ taskId,
343
+ runtime: runtimeId ?? DEFAULT_AGENT_RUNTIME,
344
+ });
341
345
  } catch (e) {
342
346
  return err(e instanceof Error ? e.message : "Failed to execute task");
343
347
  }
@@ -21,30 +21,68 @@ const VALID_WORKFLOW_STATUSES = [
21
21
  "failed",
22
22
  ] as const;
23
23
 
24
- /** Minimum Jaccard score for two workflows to count as "near duplicates". */
24
+ /**
25
+ * Minimum weighted-Jaccard score for two workflows to count as "near
26
+ * duplicates". Combined score = NAME_WEIGHT * nameJaccard +
27
+ * STEPS_WEIGHT * stepsJaccard.
28
+ *
29
+ * Why weighted-and-split rather than a single pooled Jaccard? A pooled
30
+ * Jaccard over name+step text at threshold 0.7 was flagging legitimate
31
+ * target-entity variants (e.g. "Enrich contacts" vs "Enrich accounts",
32
+ * "Daily standup digest" vs "Weekly standup digest") as duplicates,
33
+ * forcing users to pass `force: true` for every such pair and eroding
34
+ * trust in the guardrail. Splitting the signal lets the one-token
35
+ * difference in names AND prompts contribute to two independent
36
+ * Jaccards, which together pull combined similarity below 0.7 while
37
+ * structural duplicates (identical steps + near-identical name) still
38
+ * exceed the threshold.
39
+ *
40
+ * Tuning rationale:
41
+ * - 0.7 threshold preserved from the original implementation.
42
+ * - 0.5/0.5 weights (no tags). The feature spec sketched a 0.3/0.5/0.2
43
+ * split over name/steps/tags, but workflows do not persist tags in
44
+ * their definition JSON today. Without a tags signal, 0.5/0.5
45
+ * empirically separates legitimate variants (Enrich contacts vs
46
+ * accounts: 0.60; Daily vs Weekly standup: 0.68) from structural
47
+ * duplicates (identical steps with renamed workflow: 0.75+) with
48
+ * headroom on both sides. Revisit weights if tag data lands.
49
+ *
50
+ * If a future false-positive case surfaces, add a regression test in
51
+ * `workflow-tools-dedup.test.ts` → "legitimate variant tolerance" and
52
+ * re-tune rather than bumping `force: true` everywhere.
53
+ *
54
+ * See `features/chat-dedup-variant-tolerance.md`.
55
+ */
25
56
  const WORKFLOW_DEDUP_THRESHOLD = 0.7;
57
+ const WORKFLOW_NAME_WEIGHT = 0.5;
58
+ const WORKFLOW_STEPS_WEIGHT = 0.5;
26
59
 
27
60
  /**
28
- * Pull the comparable text out of a workflow definition JSON string:
29
- * name + each step's name + each step's prompt. Invalid JSON returns "".
61
+ * Split a workflow into its two comparable text signals: the name alone,
62
+ * and a concatenation of every step's name + prompt. Callers pass each
63
+ * signal through `extractKeywords` separately so name-level tokens don't
64
+ * get drowned out by the much larger step-text bag, and vice versa.
30
65
  *
31
- * Shared by findSimilarWorkflows for the candidate and each existing row.
66
+ * Malformed definition JSON falls back to `stepsText = ""`.
32
67
  */
33
- function workflowComparableText(name: string, definitionJson: string | null): string {
34
- const parts: string[] = [name];
35
- if (!definitionJson) return parts.join(" ");
68
+ function workflowSignals(
69
+ name: string,
70
+ definitionJson: string | null
71
+ ): { nameText: string; stepsText: string } {
72
+ if (!definitionJson) return { nameText: name, stepsText: "" };
36
73
  try {
37
74
  const def = JSON.parse(definitionJson);
75
+ const stepParts: string[] = [];
38
76
  if (Array.isArray(def?.steps)) {
39
77
  for (const step of def.steps) {
40
- if (typeof step?.name === "string") parts.push(step.name);
41
- if (typeof step?.prompt === "string") parts.push(step.prompt);
78
+ if (typeof step?.name === "string") stepParts.push(step.name);
79
+ if (typeof step?.prompt === "string") stepParts.push(step.prompt);
42
80
  }
43
81
  }
82
+ return { nameText: name, stepsText: stepParts.join(" ") };
44
83
  } catch {
45
- // Malformed JSON fall back to just the name.
84
+ return { nameText: name, stepsText: "" };
46
85
  }
47
- return parts.join(" ");
48
86
  }
49
87
 
50
88
  export interface SimilarWorkflowMatch {
@@ -87,9 +125,9 @@ export async function findSimilarWorkflows(
87
125
  .where(eq(workflows.projectId, projectId));
88
126
 
89
127
  const matches: SimilarWorkflowMatch[] = [];
90
- const candidateKeywords = extractKeywords(
91
- workflowComparableText(candidateName, candidateDefinitionJson)
92
- );
128
+ const candidateSignals = workflowSignals(candidateName, candidateDefinitionJson);
129
+ const candidateNameKeywords = extractKeywords(candidateSignals.nameText);
130
+ const candidateStepKeywords = extractKeywords(candidateSignals.stepsText);
93
131
  const candidateNameLower = candidateName.trim().toLowerCase();
94
132
 
95
133
  for (const row of existing) {
@@ -104,11 +142,14 @@ export async function findSimilarWorkflows(
104
142
  continue;
105
143
  }
106
144
 
107
- // Tier 2: Jaccard similarity on keywords
108
- const existingKeywords = extractKeywords(
109
- workflowComparableText(row.name, row.definition)
110
- );
111
- const similarity = jaccard(candidateKeywords, existingKeywords);
145
+ // Tier 2: weighted Jaccard name and step signals scored separately,
146
+ // then combined with WORKFLOW_NAME_WEIGHT / WORKFLOW_STEPS_WEIGHT so
147
+ // target-entity variants (same verb, different noun) are not flagged.
148
+ const existingSignals = workflowSignals(row.name, row.definition);
149
+ const nameJ = jaccard(candidateNameKeywords, extractKeywords(existingSignals.nameText));
150
+ const stepsJ = jaccard(candidateStepKeywords, extractKeywords(existingSignals.stepsText));
151
+ const similarity =
152
+ WORKFLOW_NAME_WEIGHT * nameJ + WORKFLOW_STEPS_WEIGHT * stepsJ;
112
153
  if (similarity >= WORKFLOW_DEDUP_THRESHOLD) {
113
154
  matches.push({
114
155
  id: row.id,
@@ -203,7 +244,7 @@ export function workflowTools(ctx: ToolContext) {
203
244
  .boolean()
204
245
  .optional()
205
246
  .describe(
206
- "Set to true to bypass the near-duplicate check and always create a new workflow. Only use this when the user has explicitly confirmed they want a second workflow alongside a similar existing one (e.g., 'v2', 'alternate approach'). Default false."
247
+ "Set to true to bypass the near-duplicate check and always create a new workflow. Only use this when the user has explicitly confirmed they want a second workflow alongside a similar existing one (e.g., 'v2', 'alternate approach'). The dedup check already tolerates target-entity variants (e.g., 'Enrich contacts' vs 'Enrich accounts', 'Daily' vs 'Weekly' standup digest) — so you should NOT pass force=true for those. Default false."
207
248
  ),
208
249
  },
209
250
  async (args) => {