stagent 0.9.5 → 0.10.0

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 (277) hide show
  1. package/README.md +5 -42
  2. package/dist/cli.js +42 -18
  3. package/docs/.coverage-gaps.json +13 -55
  4. package/docs/.last-generated +1 -1
  5. package/docs/features/provider-runtimes.md +4 -0
  6. package/docs/features/schedules.md +32 -4
  7. package/docs/features/settings.md +28 -5
  8. package/docs/features/tables.md +9 -2
  9. package/docs/features/workflows.md +10 -4
  10. package/docs/journeys/developer.md +15 -1
  11. package/docs/journeys/personal-use.md +21 -4
  12. package/docs/superpowers/plans/2026-04-07-instance-bootstrap.md +1691 -0
  13. package/docs/superpowers/plans/2026-04-08-schedule-orchestration.md +2983 -0
  14. package/docs/superpowers/plans/2026-04-11-schedule-maxturns-api-control.md +551 -0
  15. package/docs/superpowers/plans/2026-04-11-task-create-profile-validation.md +864 -0
  16. package/docs/superpowers/plans/2026-04-11-task-runtime-stagent-mcp-injection.md +739 -0
  17. package/docs/superpowers/specs/2026-04-08-chat-sse-resilience-hotfix-design.md +201 -0
  18. package/docs/superpowers/specs/2026-04-08-schedule-orchestration-design.md +371 -0
  19. package/docs/superpowers/specs/2026-04-08-swarm-visibility-design.md +213 -0
  20. package/package.json +3 -2
  21. package/src/__tests__/instrumentation-smoke.test.ts +15 -0
  22. package/src/app/analytics/page.tsx +1 -21
  23. package/src/app/api/chat/conversations/[id]/messages/route.ts +22 -1
  24. package/src/app/api/diagnostics/chat-streams/route.ts +65 -0
  25. package/src/app/api/instance/config/route.ts +41 -0
  26. package/src/app/api/instance/init/route.ts +34 -0
  27. package/src/app/api/instance/upgrade/check/route.ts +26 -0
  28. package/src/app/api/instance/upgrade/route.ts +96 -0
  29. package/src/app/api/instance/upgrade/status/route.ts +35 -0
  30. package/src/app/api/memory/route.ts +0 -11
  31. package/src/app/api/notifications/route.ts +4 -2
  32. package/src/app/api/projects/[id]/route.ts +5 -155
  33. package/src/app/api/projects/__tests__/delete-project.test.ts +10 -19
  34. package/src/app/api/schedules/[id]/execute/route.ts +111 -0
  35. package/src/app/api/schedules/[id]/route.ts +9 -1
  36. package/src/app/api/schedules/__tests__/execute-route.test.ts +118 -0
  37. package/src/app/api/schedules/route.ts +3 -12
  38. package/src/app/api/settings/openai/login/route.ts +22 -0
  39. package/src/app/api/settings/openai/logout/route.ts +7 -0
  40. package/src/app/api/settings/openai/route.ts +21 -1
  41. package/src/app/api/settings/providers/route.ts +35 -8
  42. package/src/app/api/tables/[id]/enrich/__tests__/route.test.ts +153 -0
  43. package/src/app/api/tables/[id]/enrich/plan/route.ts +98 -0
  44. package/src/app/api/tables/[id]/enrich/route.ts +147 -0
  45. package/src/app/api/tables/[id]/enrich/runs/route.ts +25 -0
  46. package/src/app/api/tasks/[id]/execute/route.ts +0 -21
  47. package/src/app/api/workflows/[id]/resume/route.ts +59 -0
  48. package/src/app/api/workflows/[id]/status/route.ts +22 -8
  49. package/src/app/api/workspace/context/route.ts +2 -0
  50. package/src/app/api/workspace/fix-data-dir/route.ts +81 -0
  51. package/src/app/chat/page.tsx +11 -0
  52. package/src/app/inbox/page.tsx +12 -5
  53. package/src/app/layout.tsx +42 -21
  54. package/src/app/page.tsx +0 -2
  55. package/src/app/settings/page.tsx +6 -9
  56. package/src/components/chat/__tests__/chat-session-provider.test.tsx +408 -0
  57. package/src/components/chat/chat-command-popover.tsx +2 -2
  58. package/src/components/chat/chat-input.tsx +2 -3
  59. package/src/components/chat/chat-session-provider.tsx +720 -0
  60. package/src/components/chat/chat-shell.tsx +92 -401
  61. package/src/components/instance/__tests__/instance-section.test.tsx +125 -0
  62. package/src/components/instance/instance-section.tsx +382 -0
  63. package/src/components/instance/upgrade-badge.tsx +219 -0
  64. package/src/components/notifications/__tests__/batch-proposal-review.test.tsx +95 -0
  65. package/src/components/notifications/__tests__/notification-item.test.tsx +106 -0
  66. package/src/components/notifications/batch-proposal-review.tsx +20 -5
  67. package/src/components/notifications/inbox-list.tsx +11 -2
  68. package/src/components/notifications/notification-item.tsx +56 -2
  69. package/src/components/notifications/pending-approval-host.tsx +56 -37
  70. package/src/components/schedules/schedule-create-sheet.tsx +19 -1
  71. package/src/components/schedules/schedule-edit-sheet.tsx +20 -1
  72. package/src/components/schedules/schedule-form.tsx +31 -0
  73. package/src/components/settings/__tests__/providers-runtimes-section.test.tsx +149 -0
  74. package/src/components/settings/auth-method-selector.tsx +19 -4
  75. package/src/components/settings/auth-status-badge.tsx +28 -3
  76. package/src/components/settings/openai-chatgpt-auth-control.tsx +278 -0
  77. package/src/components/settings/openai-runtime-section.tsx +7 -1
  78. package/src/components/settings/providers-runtimes-section.tsx +138 -19
  79. package/src/components/shared/app-sidebar.tsx +4 -3
  80. package/src/components/shared/command-palette.tsx +4 -5
  81. package/src/components/shared/theme-toggle.tsx +5 -24
  82. package/src/components/shared/workspace-indicator.tsx +61 -2
  83. package/src/components/tables/__tests__/table-enrichment-sheet.test.tsx +130 -0
  84. package/src/components/tables/table-create-sheet.tsx +4 -0
  85. package/src/components/tables/table-enrichment-runs.tsx +103 -0
  86. package/src/components/tables/table-enrichment-sheet.tsx +538 -0
  87. package/src/components/tables/table-spreadsheet.tsx +29 -5
  88. package/src/components/tables/table-toolbar.tsx +10 -1
  89. package/src/components/tasks/kanban-board.tsx +1 -0
  90. package/src/components/tasks/kanban-column.tsx +53 -14
  91. package/src/components/tasks/task-bento-grid.tsx +19 -0
  92. package/src/components/tasks/task-card.tsx +26 -3
  93. package/src/components/tasks/task-chip-bar.tsx +24 -0
  94. package/src/components/tasks/task-result-renderer.tsx +1 -1
  95. package/src/components/workflows/delay-step-body.tsx +109 -0
  96. package/src/components/workflows/hooks/use-workflow-status.ts +50 -0
  97. package/src/components/workflows/loop-status-view.tsx +1 -1
  98. package/src/components/workflows/shared/step-result.tsx +78 -0
  99. package/src/components/workflows/shared/workflow-header.tsx +141 -0
  100. package/src/components/workflows/shared/workflow-loading-skeleton.tsx +36 -0
  101. package/src/components/workflows/swarm-dashboard.tsx +2 -15
  102. package/src/components/workflows/views/loop-pattern-view.tsx +137 -0
  103. package/src/components/workflows/views/sequence-pattern-view.tsx +511 -0
  104. package/src/components/workflows/workflow-form-view.tsx +133 -16
  105. package/src/components/workflows/workflow-status-view.tsx +30 -740
  106. package/src/instrumentation-node.ts +94 -0
  107. package/src/instrumentation.ts +4 -48
  108. package/src/lib/agents/__tests__/claude-agent.test.ts +199 -0
  109. package/src/lib/agents/__tests__/execution-manager.test.ts +1 -27
  110. package/src/lib/agents/__tests__/failure-reason.test.ts +68 -0
  111. package/src/lib/agents/__tests__/learned-context.test.ts +0 -11
  112. package/src/lib/agents/__tests__/learning-session.test.ts +158 -0
  113. package/src/lib/agents/__tests__/pattern-extractor.test.ts +48 -0
  114. package/src/lib/agents/claude-agent.ts +155 -18
  115. package/src/lib/agents/execution-manager.ts +0 -35
  116. package/src/lib/agents/learned-context.ts +0 -12
  117. package/src/lib/agents/learning-session.ts +18 -5
  118. package/src/lib/agents/profiles/__tests__/registry.test.ts +6 -4
  119. package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +70 -0
  120. package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +32 -0
  121. package/src/lib/agents/runtime/__tests__/openai-codex-auth.test.ts +118 -0
  122. package/src/lib/agents/runtime/codex-app-server-client.ts +11 -5
  123. package/src/lib/agents/runtime/openai-codex-auth.ts +389 -0
  124. package/src/lib/agents/runtime/openai-codex.ts +29 -60
  125. package/src/lib/agents/runtime/types.ts +8 -0
  126. package/src/lib/book/chapter-mapping.ts +11 -0
  127. package/src/lib/book/content.ts +10 -0
  128. package/src/lib/chat/__tests__/active-streams.test.ts +49 -0
  129. package/src/lib/chat/__tests__/finalize-safety-net.test.ts +139 -0
  130. package/src/lib/chat/__tests__/reconcile.test.ts +137 -0
  131. package/src/lib/chat/__tests__/stream-telemetry.test.ts +151 -0
  132. package/src/lib/chat/active-streams.ts +27 -0
  133. package/src/lib/chat/codex-engine.ts +16 -17
  134. package/src/lib/chat/context-builder.ts +5 -3
  135. package/src/lib/chat/engine.ts +50 -3
  136. package/src/lib/chat/reconcile.ts +117 -0
  137. package/src/lib/chat/stagent-tools.ts +1 -0
  138. package/src/lib/chat/stream-telemetry.ts +132 -0
  139. package/src/lib/chat/suggested-prompts.ts +28 -1
  140. package/src/lib/chat/system-prompt.ts +26 -1
  141. package/src/lib/chat/tool-catalog.ts +2 -1
  142. package/src/lib/chat/tools/__tests__/enrich-table-tool.test.ts +127 -0
  143. package/src/lib/chat/tools/__tests__/schedule-tools.test.ts +261 -0
  144. package/src/lib/chat/tools/__tests__/task-tools.test.ts +352 -0
  145. package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +217 -0
  146. package/src/lib/chat/tools/document-tools.ts +29 -13
  147. package/src/lib/chat/tools/helpers.ts +39 -0
  148. package/src/lib/chat/tools/notification-tools.ts +9 -5
  149. package/src/lib/chat/tools/project-tools.ts +33 -0
  150. package/src/lib/chat/tools/schedule-tools.ts +44 -11
  151. package/src/lib/chat/tools/table-tools.ts +71 -0
  152. package/src/lib/chat/tools/task-tools.ts +84 -20
  153. package/src/lib/chat/tools/workflow-tools.ts +234 -32
  154. package/src/lib/constants/settings.ts +8 -18
  155. package/src/lib/data/__tests__/clear.test.ts +56 -2
  156. package/src/lib/data/clear.ts +20 -15
  157. package/src/lib/data/delete-project.ts +171 -0
  158. package/src/lib/db/__tests__/bootstrap.test.ts +1 -1
  159. package/src/lib/db/bootstrap.ts +45 -16
  160. package/src/lib/db/index.ts +5 -0
  161. package/src/lib/db/migrations/0009_add_app_instances.sql +25 -0
  162. package/src/lib/db/migrations/0024_add_workflow_resume_at.sql +10 -0
  163. package/src/lib/db/migrations/0025_drop_app_instances.sql +3 -0
  164. package/src/lib/db/migrations/0026_drop_license.sql +3 -0
  165. package/src/lib/db/migrations/meta/_journal.json +21 -0
  166. package/src/lib/db/schema.ts +68 -23
  167. package/src/lib/environment/workspace-context.ts +13 -1
  168. package/src/lib/import/dedup.ts +4 -54
  169. package/src/lib/instance/__tests__/bootstrap.test.ts +362 -0
  170. package/src/lib/instance/__tests__/detect.test.ts +115 -0
  171. package/src/lib/instance/__tests__/fingerprint.test.ts +48 -0
  172. package/src/lib/instance/__tests__/git-ops.test.ts +95 -0
  173. package/src/lib/instance/__tests__/settings.test.ts +83 -0
  174. package/src/lib/instance/__tests__/upgrade-poller.test.ts +131 -0
  175. package/src/lib/instance/bootstrap.ts +270 -0
  176. package/src/lib/instance/detect.ts +49 -0
  177. package/src/lib/instance/fingerprint.ts +78 -0
  178. package/src/lib/instance/git-ops.ts +95 -0
  179. package/src/lib/instance/settings.ts +61 -0
  180. package/src/lib/instance/types.ts +77 -0
  181. package/src/lib/instance/upgrade-poller.ts +153 -0
  182. package/src/lib/notifications/__tests__/visibility.test.ts +51 -0
  183. package/src/lib/notifications/visibility.ts +33 -0
  184. package/src/lib/schedules/__tests__/collision-check.test.ts +93 -0
  185. package/src/lib/schedules/__tests__/config.test.ts +62 -0
  186. package/src/lib/schedules/__tests__/firing-metrics.test.ts +99 -0
  187. package/src/lib/schedules/__tests__/integration.test.ts +82 -0
  188. package/src/lib/schedules/__tests__/slot-claim.test.ts +242 -0
  189. package/src/lib/schedules/__tests__/tick-scheduler.test.ts +102 -0
  190. package/src/lib/schedules/__tests__/turn-budget.test.ts +228 -0
  191. package/src/lib/schedules/collision-check.ts +105 -0
  192. package/src/lib/schedules/config.ts +53 -0
  193. package/src/lib/schedules/scheduler.ts +232 -13
  194. package/src/lib/schedules/slot-claim.ts +105 -0
  195. package/src/lib/settings/__tests__/openai-auth.test.ts +101 -0
  196. package/src/lib/settings/__tests__/openai-login-manager.test.ts +64 -0
  197. package/src/lib/settings/__tests__/runtime-setup.test.ts +33 -0
  198. package/src/lib/settings/openai-auth.ts +105 -10
  199. package/src/lib/settings/openai-login-manager.ts +260 -0
  200. package/src/lib/settings/runtime-setup.ts +14 -4
  201. package/src/lib/tables/__tests__/enrichment-planner.test.ts +124 -0
  202. package/src/lib/tables/__tests__/enrichment.test.ts +147 -0
  203. package/src/lib/tables/enrichment-planner.ts +454 -0
  204. package/src/lib/tables/enrichment.ts +328 -0
  205. package/src/lib/tables/query-builder.ts +5 -2
  206. package/src/lib/tables/trigger-evaluator.ts +3 -2
  207. package/src/lib/theme.ts +71 -0
  208. package/src/lib/usage/ledger.ts +2 -18
  209. package/src/lib/util/__tests__/similarity.test.ts +106 -0
  210. package/src/lib/util/similarity.ts +77 -0
  211. package/src/lib/utils/format-timestamp.ts +24 -0
  212. package/src/lib/utils/stagent-paths.ts +12 -0
  213. package/src/lib/validators/__tests__/blueprint.test.ts +172 -0
  214. package/src/lib/validators/__tests__/settings.test.ts +10 -0
  215. package/src/lib/validators/blueprint.ts +70 -9
  216. package/src/lib/validators/profile.ts +2 -2
  217. package/src/lib/validators/settings.ts +3 -1
  218. package/src/lib/workflows/__tests__/delay.test.ts +196 -0
  219. package/src/lib/workflows/__tests__/engine.test.ts +8 -0
  220. package/src/lib/workflows/__tests__/loop-executor.test.ts +54 -0
  221. package/src/lib/workflows/__tests__/post-action.test.ts +108 -0
  222. package/src/lib/workflows/blueprints/instantiator.ts +22 -1
  223. package/src/lib/workflows/blueprints/types.ts +10 -2
  224. package/src/lib/workflows/delay.ts +106 -0
  225. package/src/lib/workflows/engine.ts +207 -4
  226. package/src/lib/workflows/loop-executor.ts +349 -24
  227. package/src/lib/workflows/post-action.ts +91 -0
  228. package/src/lib/workflows/types.ts +166 -1
  229. package/src/app/api/license/checkout/route.ts +0 -28
  230. package/src/app/api/license/portal/route.ts +0 -26
  231. package/src/app/api/license/route.ts +0 -89
  232. package/src/app/api/license/usage/route.ts +0 -63
  233. package/src/app/api/marketplace/browse/route.ts +0 -15
  234. package/src/app/api/marketplace/import/route.ts +0 -28
  235. package/src/app/api/marketplace/publish/route.ts +0 -40
  236. package/src/app/api/onboarding/email/route.ts +0 -53
  237. package/src/app/api/settings/telemetry/route.ts +0 -14
  238. package/src/app/api/sync/export/route.ts +0 -54
  239. package/src/app/api/sync/restore/route.ts +0 -37
  240. package/src/app/api/sync/sessions/route.ts +0 -24
  241. package/src/app/auth/callback/route.ts +0 -73
  242. package/src/app/marketplace/page.tsx +0 -19
  243. package/src/components/analytics/analytics-gate-card.tsx +0 -101
  244. package/src/components/marketplace/blueprint-card.tsx +0 -61
  245. package/src/components/marketplace/marketplace-browser.tsx +0 -131
  246. package/src/components/onboarding/email-capture-card.tsx +0 -104
  247. package/src/components/settings/activation-form.tsx +0 -95
  248. package/src/components/settings/cloud-account-section.tsx +0 -147
  249. package/src/components/settings/cloud-sync-section.tsx +0 -155
  250. package/src/components/settings/subscription-section.tsx +0 -410
  251. package/src/components/settings/telemetry-section.tsx +0 -80
  252. package/src/components/shared/premium-gate-overlay.tsx +0 -50
  253. package/src/components/shared/schedule-gate-dialog.tsx +0 -64
  254. package/src/components/shared/upgrade-banner.tsx +0 -112
  255. package/src/hooks/use-supabase-auth.ts +0 -79
  256. package/src/lib/billing/email.ts +0 -54
  257. package/src/lib/billing/products.ts +0 -80
  258. package/src/lib/billing/stripe.ts +0 -101
  259. package/src/lib/cloud/supabase-browser.ts +0 -32
  260. package/src/lib/cloud/supabase-client.ts +0 -56
  261. package/src/lib/license/__tests__/features.test.ts +0 -56
  262. package/src/lib/license/__tests__/key-format.test.ts +0 -88
  263. package/src/lib/license/__tests__/manager.test.ts +0 -64
  264. package/src/lib/license/__tests__/tier-limits.test.ts +0 -79
  265. package/src/lib/license/cloud-validation.ts +0 -60
  266. package/src/lib/license/features.ts +0 -44
  267. package/src/lib/license/key-format.ts +0 -101
  268. package/src/lib/license/limit-check.ts +0 -111
  269. package/src/lib/license/limit-queries.ts +0 -51
  270. package/src/lib/license/manager.ts +0 -345
  271. package/src/lib/license/notifications.ts +0 -59
  272. package/src/lib/license/tier-limits.ts +0 -71
  273. package/src/lib/marketplace/marketplace-client.ts +0 -107
  274. package/src/lib/sync/cloud-sync.ts +0 -235
  275. package/src/lib/telemetry/conversion-events.ts +0 -71
  276. package/src/lib/telemetry/queue.ts +0 -122
  277. package/src/lib/validators/license.ts +0 -33
@@ -0,0 +1,217 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ interface WorkflowRow {
4
+ id: string;
5
+ name: string;
6
+ definition: string | null;
7
+ projectId: string | null;
8
+ }
9
+
10
+ const { mockWorkflowRows } = vi.hoisted(() => ({
11
+ mockWorkflowRows: { value: [] as WorkflowRow[] },
12
+ }));
13
+
14
+ // Minimal drizzle query builder stub — supports
15
+ // db.select({...}).from(table).where(...)
16
+ // by returning a thenable that resolves to mockWorkflowRows.value.
17
+ vi.mock("@/lib/db", () => {
18
+ const builder = {
19
+ from() {
20
+ return this;
21
+ },
22
+ where() {
23
+ return this;
24
+ },
25
+ then<TResolve>(resolve: (rows: WorkflowRow[]) => TResolve) {
26
+ return Promise.resolve(mockWorkflowRows.value).then(resolve);
27
+ },
28
+ };
29
+ return {
30
+ db: {
31
+ select: () => builder,
32
+ },
33
+ };
34
+ });
35
+
36
+ // Stub the schema import so drizzle-orm doesn't try to read a real table.
37
+ vi.mock("@/lib/db/schema", () => ({
38
+ workflows: { projectId: "projectId" },
39
+ tasks: {},
40
+ agentLogs: {},
41
+ notifications: {},
42
+ documents: {},
43
+ workflowDocumentInputs: {},
44
+ }));
45
+
46
+ // Stub drizzle-orm operators used in workflow-tools.ts — the tests only
47
+ // care about the return value of the builder, not the operator objects.
48
+ vi.mock("drizzle-orm", () => ({
49
+ eq: () => ({}),
50
+ and: () => ({}),
51
+ desc: () => ({}),
52
+ inArray: () => ({}),
53
+ like: () => ({}),
54
+ }));
55
+
56
+ import { findSimilarWorkflows } from "../workflow-tools";
57
+
58
+ function setRows(rows: WorkflowRow[]) {
59
+ mockWorkflowRows.value = rows;
60
+ }
61
+
62
+ describe("findSimilarWorkflows", () => {
63
+ beforeEach(() => {
64
+ setRows([]);
65
+ });
66
+
67
+ it("returns [] when projectId is null (no cross-project dedup)", async () => {
68
+ setRows([
69
+ {
70
+ id: "wf1",
71
+ name: "Research Customer Feedback",
72
+ definition: JSON.stringify({
73
+ pattern: "sequence",
74
+ steps: [{ id: "s1", name: "Research customer feedback", prompt: "do research" }],
75
+ }),
76
+ projectId: null,
77
+ },
78
+ ]);
79
+
80
+ const result = await findSimilarWorkflows(
81
+ null,
82
+ "Research Customer Feedback",
83
+ JSON.stringify({ pattern: "sequence", steps: [] })
84
+ );
85
+ expect(result).toEqual([]);
86
+ });
87
+
88
+ it("returns [] when no workflows exist in the project", async () => {
89
+ setRows([]);
90
+ const result = await findSimilarWorkflows(
91
+ "proj_a",
92
+ "Any name",
93
+ JSON.stringify({ pattern: "sequence", steps: [] })
94
+ );
95
+ expect(result).toEqual([]);
96
+ });
97
+
98
+ it("matches exact name (case-insensitive) with similarity 1.0", async () => {
99
+ setRows([
100
+ {
101
+ id: "wf1",
102
+ name: "Research Customer Feedback",
103
+ definition: null,
104
+ projectId: "proj_a",
105
+ },
106
+ ]);
107
+
108
+ const result = await findSimilarWorkflows(
109
+ "proj_a",
110
+ "research customer feedback",
111
+ JSON.stringify({ pattern: "sequence", steps: [] })
112
+ );
113
+
114
+ expect(result).toHaveLength(1);
115
+ expect(result[0]).toMatchObject({
116
+ id: "wf1",
117
+ similarity: 1,
118
+ });
119
+ expect(result[0].reason).toContain("Same name");
120
+ });
121
+
122
+ it("matches on Jaccard similarity over step names + prompts (redesign scenario)", async () => {
123
+ // Simulates the bug scenario: LLM "redesigns" a workflow mid-conversation,
124
+ // using mostly the same vocabulary as the original. The definitions are
125
+ // near-identical (as redesigns typically are in practice) so Jaccard
126
+ // should exceed the 0.7 threshold.
127
+ const sharedSteps = [
128
+ { id: "s1", name: "Research customer cohort", prompt: "Investigate customer research cohort feedback insights" },
129
+ { id: "s2", name: "Interview protocol draft", prompt: "Draft customer interview questions protocol script" },
130
+ { id: "s3", name: "Synthesize findings", prompt: "Summarize customer research findings insights report" },
131
+ ];
132
+ setRows([
133
+ {
134
+ id: "wf1",
135
+ name: "Customer Discovery Pipeline",
136
+ definition: JSON.stringify({ pattern: "sequence", steps: sharedSteps }),
137
+ projectId: "proj_a",
138
+ },
139
+ ]);
140
+
141
+ const result = await findSimilarWorkflows(
142
+ "proj_a",
143
+ "Customer Discovery Workflow v2",
144
+ JSON.stringify({ pattern: "sequence", steps: sharedSteps })
145
+ );
146
+
147
+ expect(result.length).toBeGreaterThanOrEqual(1);
148
+ expect(result[0].id).toBe("wf1");
149
+ expect(result[0].similarity).toBeGreaterThanOrEqual(0.7);
150
+ });
151
+
152
+ it("does NOT match when names and step text are completely different", async () => {
153
+ setRows([
154
+ {
155
+ id: "wf1",
156
+ name: "Deploy frontend release",
157
+ definition: JSON.stringify({
158
+ pattern: "sequence",
159
+ steps: [{ id: "s1", name: "Deploy staging", prompt: "Push release artifact to staging environment" }],
160
+ }),
161
+ projectId: "proj_a",
162
+ },
163
+ ]);
164
+
165
+ const result = await findSimilarWorkflows(
166
+ "proj_a",
167
+ "Customer interview analysis",
168
+ JSON.stringify({
169
+ pattern: "sequence",
170
+ steps: [{ id: "s2", name: "Summarize interviews", prompt: "Pull insights from recent customer interviews" }],
171
+ })
172
+ );
173
+
174
+ expect(result).toEqual([]);
175
+ });
176
+
177
+ it("caps results at 3 and sorts by similarity descending", async () => {
178
+ // Four rows, all exact-name matches (similarity 1.0). Expect exactly 3 returned.
179
+ setRows(
180
+ Array.from({ length: 4 }).map((_, i) => ({
181
+ id: `wf${i}`,
182
+ name: "Duplicate Workflow",
183
+ definition: null,
184
+ projectId: "proj_a",
185
+ }))
186
+ );
187
+
188
+ const result = await findSimilarWorkflows(
189
+ "proj_a",
190
+ "Duplicate Workflow",
191
+ JSON.stringify({ pattern: "sequence", steps: [] })
192
+ );
193
+
194
+ expect(result).toHaveLength(3);
195
+ expect(result.every((r) => r.similarity === 1)).toBe(true);
196
+ });
197
+
198
+ it("handles malformed definition JSON without crashing", async () => {
199
+ setRows([
200
+ {
201
+ id: "wf1",
202
+ name: "Legit Workflow",
203
+ definition: "not-json-at-all",
204
+ projectId: "proj_a",
205
+ },
206
+ ]);
207
+
208
+ // Should not throw — just degrades to name-only comparison.
209
+ const result = await findSimilarWorkflows(
210
+ "proj_a",
211
+ "Legit Workflow",
212
+ "also not json"
213
+ );
214
+ expect(result).toHaveLength(1);
215
+ expect(result[0].similarity).toBe(1); // exact name match
216
+ });
217
+ });
@@ -3,7 +3,7 @@ import { z } from "zod";
3
3
  import { db } from "@/lib/db";
4
4
  import { documents } from "@/lib/db/schema";
5
5
  import { eq, and, desc } from "drizzle-orm";
6
- import { ok, err, type ToolContext } from "./helpers";
6
+ import { ok, err, resolveEntityId, type ToolContext } from "./helpers";
7
7
  import { access, stat, copyFile, mkdir } from "fs/promises";
8
8
  import { basename, extname, join } from "path";
9
9
  import crypto from "crypto";
@@ -105,6 +105,10 @@ export function documentTools(ctx: ToolContext) {
105
105
  },
106
106
  async (args) => {
107
107
  try {
108
+ const resolved = await resolveEntityId(documents, documents.id, args.documentId);
109
+ if ("error" in resolved) return err(resolved.error);
110
+ const documentId = resolved.id;
111
+
108
112
  const doc = await db
109
113
  .select({
110
114
  id: documents.id,
@@ -122,10 +126,10 @@ export function documentTools(ctx: ToolContext) {
122
126
  updatedAt: documents.updatedAt,
123
127
  })
124
128
  .from(documents)
125
- .where(eq(documents.id, args.documentId))
129
+ .where(eq(documents.id, documentId))
126
130
  .get();
127
131
 
128
- if (!doc) return err(`Document not found: ${args.documentId}`);
132
+ if (!doc) return err(`Document not found: ${documentId}`);
129
133
  ctx.onToolResult?.("get_document", doc);
130
134
  return ok(doc);
131
135
  } catch (e) {
@@ -204,13 +208,17 @@ export function documentTools(ctx: ToolContext) {
204
208
  },
205
209
  async (args) => {
206
210
  try {
211
+ const resolved = await resolveEntityId(documents, documents.id, args.documentId);
212
+ if ("error" in resolved) return err(resolved.error);
213
+ const documentId = resolved.id;
214
+
207
215
  const doc = await db
208
216
  .select()
209
217
  .from(documents)
210
- .where(eq(documents.id, args.documentId))
218
+ .where(eq(documents.id, documentId))
211
219
  .get();
212
220
 
213
- if (!doc) return err(`Document not found: ${args.documentId}`);
221
+ if (!doc) return err(`Document not found: ${documentId}`);
214
222
 
215
223
  const updates: Record<string, unknown> = { updatedAt: new Date() };
216
224
 
@@ -232,10 +240,10 @@ export function documentTools(ctx: ToolContext) {
232
240
  await db
233
241
  .update(documents)
234
242
  .set(updates)
235
- .where(eq(documents.id, args.documentId));
243
+ .where(eq(documents.id, documentId));
236
244
 
237
245
  if (args.reprocess) {
238
- processDocument(args.documentId).catch(() => {});
246
+ processDocument(documentId).catch(() => {});
239
247
  }
240
248
 
241
249
  const updatedFields = [];
@@ -243,7 +251,7 @@ export function documentTools(ctx: ToolContext) {
243
251
  if (args.reprocess) updatedFields.push("processingStatus");
244
252
 
245
253
  const result = {
246
- documentId: args.documentId,
254
+ documentId,
247
255
  updatedFields,
248
256
  processingStatus: args.reprocess ? "queued" : doc.status,
249
257
  };
@@ -264,13 +272,17 @@ export function documentTools(ctx: ToolContext) {
264
272
  },
265
273
  async (args) => {
266
274
  try {
275
+ const resolved = await resolveEntityId(documents, documents.id, args.documentId);
276
+ if ("error" in resolved) return err(resolved.error);
277
+ const documentId = resolved.id;
278
+
267
279
  const doc = await db
268
280
  .select()
269
281
  .from(documents)
270
- .where(eq(documents.id, args.documentId))
282
+ .where(eq(documents.id, documentId))
271
283
  .get();
272
284
 
273
- if (!doc) return err(`Document not found: ${args.documentId}`);
285
+ if (!doc) return err(`Document not found: ${documentId}`);
274
286
 
275
287
  // Check task linkage
276
288
  if (doc.taskId && !args.cascadeDelete) {
@@ -285,7 +297,7 @@ export function documentTools(ctx: ToolContext) {
285
297
  // File may already be deleted
286
298
  }
287
299
 
288
- await db.delete(documents).where(eq(documents.id, args.documentId));
300
+ await db.delete(documents).where(eq(documents.id, documentId));
289
301
 
290
302
  const result = {
291
303
  success: true,
@@ -308,6 +320,10 @@ export function documentTools(ctx: ToolContext) {
308
320
  },
309
321
  async (args) => {
310
322
  try {
323
+ const resolved = await resolveEntityId(documents, documents.id, args.documentId);
324
+ if ("error" in resolved) return err(resolved.error);
325
+ const documentId = resolved.id;
326
+
311
327
  const doc = await db
312
328
  .select({
313
329
  id: documents.id,
@@ -316,10 +332,10 @@ export function documentTools(ctx: ToolContext) {
316
332
  extractedText: documents.extractedText,
317
333
  })
318
334
  .from(documents)
319
- .where(eq(documents.id, args.documentId))
335
+ .where(eq(documents.id, documentId))
320
336
  .get();
321
337
 
322
- if (!doc) return err(`Document not found: ${args.documentId}`);
338
+ if (!doc) return err(`Document not found: ${documentId}`);
323
339
  if (doc.status !== "ready")
324
340
  return err(`Document not ready (status: ${doc.status}). Wait for preprocessing to complete.`);
325
341
  if (!doc.extractedText)
@@ -2,6 +2,11 @@
2
2
  * Shared helpers and types for Stagent chat MCP tools.
3
3
  */
4
4
 
5
+ import { db } from "@/lib/db";
6
+ import { like } from "drizzle-orm";
7
+ import type { SQLiteTableWithColumns } from "drizzle-orm/sqlite-core";
8
+ import type { SQLiteColumn } from "drizzle-orm/sqlite-core";
9
+
5
10
  /** Context passed to each tool factory — provides project scoping and entity callbacks. */
6
11
  export interface ToolContext {
7
12
  projectId?: string | null;
@@ -22,3 +27,37 @@ export function err(message: string) {
22
27
  isError: true as const,
23
28
  };
24
29
  }
30
+
31
+ /**
32
+ * Resolve an entity ID that may be a prefix (8+ chars) to the full UUID.
33
+ * Uses LIKE 'prefix%' which hits the primary key B-tree index on SQLite.
34
+ *
35
+ * Fast path: IDs >=32 chars are returned as-is (already full UUIDs).
36
+ */
37
+ export async function resolveEntityId(
38
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
39
+ table: SQLiteTableWithColumns<any>,
40
+ idColumn: SQLiteColumn,
41
+ rawId: string,
42
+ ): Promise<{ id: string } | { error: string }> {
43
+ // Full UUIDs are 36 chars (with hyphens) or 32 (without) — skip prefix search
44
+ if (rawId.length >= 32) {
45
+ return { id: rawId };
46
+ }
47
+
48
+ const matches = await db
49
+ .select({ id: idColumn })
50
+ .from(table)
51
+ .where(like(idColumn, `${rawId}%`))
52
+ .limit(2);
53
+
54
+ if (matches.length === 0) {
55
+ return { error: `No entity found matching ID prefix: ${rawId}` };
56
+ }
57
+ if (matches.length > 1) {
58
+ return {
59
+ error: `Ambiguous ID prefix "${rawId}" matches multiple entities: ${matches.map((m) => m.id).join(", ")}. Use the full ID.`,
60
+ };
61
+ }
62
+ return { id: matches[0].id as string };
63
+ }
@@ -3,7 +3,7 @@ import { z } from "zod";
3
3
  import { db } from "@/lib/db";
4
4
  import { notifications } from "@/lib/db/schema";
5
5
  import { eq, isNull, desc } from "drizzle-orm";
6
- import { ok, err, type ToolContext } from "./helpers";
6
+ import { ok, err, resolveEntityId, type ToolContext } from "./helpers";
7
7
 
8
8
  export function notificationTools(_ctx: ToolContext) {
9
9
  return [
@@ -73,13 +73,17 @@ export function notificationTools(_ctx: ToolContext) {
73
73
  },
74
74
  async (args) => {
75
75
  try {
76
+ const resolved = await resolveEntityId(notifications, notifications.id, args.notificationId);
77
+ if ("error" in resolved) return err(resolved.error);
78
+ const notificationId = resolved.id;
79
+
76
80
  const notification = await db
77
81
  .select()
78
82
  .from(notifications)
79
- .where(eq(notifications.id, args.notificationId))
83
+ .where(eq(notifications.id, notificationId))
80
84
  .get();
81
85
 
82
- if (!notification) return err(`Notification not found: ${args.notificationId}`);
86
+ if (!notification) return err(`Notification not found: ${notificationId}`);
83
87
  if (notification.response) return err("Already responded to this notification");
84
88
 
85
89
  const responseData = {
@@ -98,7 +102,7 @@ export function notificationTools(_ctx: ToolContext) {
98
102
  respondedAt: new Date(),
99
103
  read: true,
100
104
  })
101
- .where(eq(notifications.id, args.notificationId));
105
+ .where(eq(notifications.id, notificationId));
102
106
 
103
107
  // Save permanent permission if requested
104
108
  if (args.behavior === "allow" && args.alwaysAllow && notification.toolName && notification.toolInput) {
@@ -115,7 +119,7 @@ export function notificationTools(_ctx: ToolContext) {
115
119
 
116
120
  return ok({
117
121
  message: `Notification ${args.behavior === "allow" ? "approved" : "denied"}`,
118
- notificationId: args.notificationId,
122
+ notificationId,
119
123
  alwaysAllow: args.alwaysAllow ?? false,
120
124
  });
121
125
  } catch (e) {
@@ -85,5 +85,38 @@ export function projectTools(ctx: ToolContext) {
85
85
  }
86
86
  }
87
87
  ),
88
+
89
+ defineTool(
90
+ "delete_project",
91
+ "Permanently delete a project and all its resources (tasks, tables, schedules, " +
92
+ "documents, app instances). This is irreversible. Use to clean up orphaned or " +
93
+ "unwanted projects. Always confirm with the user before calling.",
94
+ {
95
+ projectId: z
96
+ .string()
97
+ .describe("The ID of the project to delete"),
98
+ },
99
+ async (args) => {
100
+ try {
101
+ const { deleteProjectCascade } = await import(
102
+ "@/lib/data/delete-project"
103
+ );
104
+
105
+ const deleted = deleteProjectCascade(args.projectId);
106
+ if (!deleted) {
107
+ return err(`Project "${args.projectId}" not found`);
108
+ }
109
+
110
+ return ok({
111
+ projectId: args.projectId,
112
+ message: `Project "${args.projectId}" and all its resources have been deleted.`,
113
+ });
114
+ } catch (e) {
115
+ return err(
116
+ e instanceof Error ? e.message : "Failed to delete project",
117
+ );
118
+ }
119
+ }
120
+ ),
88
121
  ];
89
122
  }
@@ -3,7 +3,7 @@ import { z } from "zod";
3
3
  import { db } from "@/lib/db";
4
4
  import { schedules } from "@/lib/db/schema";
5
5
  import { eq, and, desc } from "drizzle-orm";
6
- import { ok, err, type ToolContext } from "./helpers";
6
+ import { ok, err, resolveEntityId, type ToolContext } from "./helpers";
7
7
  import { analyzePromptEfficiency } from "@/lib/schedules/prompt-analyzer";
8
8
 
9
9
  const VALID_SCHEDULE_STATUSES = [
@@ -69,6 +69,13 @@ export function scheduleTools(ctx: ToolContext) {
69
69
  .number()
70
70
  .optional()
71
71
  .describe("Auto-expire after this many hours"),
72
+ maxTurns: z
73
+ .number()
74
+ .int()
75
+ .min(10)
76
+ .max(500)
77
+ .optional()
78
+ .describe("Hard cap on turns per firing (10-500). Omit to inherit the system default."),
72
79
  },
73
80
  async (args) => {
74
81
  try {
@@ -147,6 +154,8 @@ export function scheduleTools(ctx: ToolContext) {
147
154
  recurs: true,
148
155
  status: "active",
149
156
  maxFirings: args.maxFirings ?? null,
157
+ maxTurns: args.maxTurns ?? null,
158
+ maxTurnsSetAt: args.maxTurns !== undefined ? now : null,
150
159
  firingCount: 0,
151
160
  expiresAt,
152
161
  nextFireAt,
@@ -184,13 +193,17 @@ export function scheduleTools(ctx: ToolContext) {
184
193
  },
185
194
  async (args) => {
186
195
  try {
196
+ const resolved = await resolveEntityId(schedules, schedules.id, args.scheduleId);
197
+ if ("error" in resolved) return err(resolved.error);
198
+ const scheduleId = resolved.id;
199
+
187
200
  const schedule = await db
188
201
  .select()
189
202
  .from(schedules)
190
- .where(eq(schedules.id, args.scheduleId))
203
+ .where(eq(schedules.id, scheduleId))
191
204
  .get();
192
205
 
193
- if (!schedule) return err(`Schedule not found: ${args.scheduleId}`);
206
+ if (!schedule) return err(`Schedule not found: ${scheduleId}`);
194
207
  ctx.onToolResult?.("get_schedule", schedule);
195
208
  return ok(schedule);
196
209
  } catch (e) {
@@ -216,16 +229,28 @@ export function scheduleTools(ctx: ToolContext) {
216
229
  .describe("New status (use 'paused' to pause, 'active' to resume)"),
217
230
  assignedAgent: z.string().optional().describe("New runtime ID"),
218
231
  agentProfile: z.string().optional().describe("New agent profile"),
232
+ maxTurns: z
233
+ .number()
234
+ .int()
235
+ .min(10)
236
+ .max(500)
237
+ .optional()
238
+ .nullable()
239
+ .describe("Hard cap on turns per firing (10-500). Pass null to clear an override back to the system default."),
219
240
  },
220
241
  async (args) => {
221
242
  try {
243
+ const resolved = await resolveEntityId(schedules, schedules.id, args.scheduleId);
244
+ if ("error" in resolved) return err(resolved.error);
245
+ const scheduleId = resolved.id;
246
+
222
247
  const existing = await db
223
248
  .select()
224
249
  .from(schedules)
225
- .where(eq(schedules.id, args.scheduleId))
250
+ .where(eq(schedules.id, scheduleId))
226
251
  .get();
227
252
 
228
- if (!existing) return err(`Schedule not found: ${args.scheduleId}`);
253
+ if (!existing) return err(`Schedule not found: ${scheduleId}`);
229
254
 
230
255
  const updates: Record<string, unknown> = { updatedAt: new Date() };
231
256
  if (args.name !== undefined) updates.name = args.name;
@@ -233,6 +258,10 @@ export function scheduleTools(ctx: ToolContext) {
233
258
  if (args.status !== undefined) updates.status = args.status;
234
259
  if (args.assignedAgent !== undefined) updates.assignedAgent = args.assignedAgent;
235
260
  if (args.agentProfile !== undefined) updates.agentProfile = args.agentProfile;
261
+ if (args.maxTurns !== undefined) {
262
+ updates.maxTurns = args.maxTurns;
263
+ updates.maxTurnsSetAt = args.maxTurns === null ? null : new Date();
264
+ }
236
265
 
237
266
  if (args.interval) {
238
267
  const { parseInterval, computeNextFireTime } = await import(
@@ -267,12 +296,12 @@ export function scheduleTools(ctx: ToolContext) {
267
296
  await db
268
297
  .update(schedules)
269
298
  .set(updates)
270
- .where(eq(schedules.id, args.scheduleId));
299
+ .where(eq(schedules.id, scheduleId));
271
300
 
272
301
  const [schedule] = await db
273
302
  .select()
274
303
  .from(schedules)
275
- .where(eq(schedules.id, args.scheduleId));
304
+ .where(eq(schedules.id, scheduleId));
276
305
 
277
306
  ctx.onToolResult?.("update_schedule", schedule);
278
307
  return ok(schedule);
@@ -290,16 +319,20 @@ export function scheduleTools(ctx: ToolContext) {
290
319
  },
291
320
  async (args) => {
292
321
  try {
322
+ const resolved = await resolveEntityId(schedules, schedules.id, args.scheduleId);
323
+ if ("error" in resolved) return err(resolved.error);
324
+ const scheduleId = resolved.id;
325
+
293
326
  const existing = await db
294
327
  .select()
295
328
  .from(schedules)
296
- .where(eq(schedules.id, args.scheduleId))
329
+ .where(eq(schedules.id, scheduleId))
297
330
  .get();
298
331
 
299
- if (!existing) return err(`Schedule not found: ${args.scheduleId}`);
332
+ if (!existing) return err(`Schedule not found: ${scheduleId}`);
300
333
 
301
- await db.delete(schedules).where(eq(schedules.id, args.scheduleId));
302
- return ok({ message: "Schedule deleted", scheduleId: args.scheduleId, name: existing.name });
334
+ await db.delete(schedules).where(eq(schedules.id, scheduleId));
335
+ return ok({ message: "Schedule deleted", scheduleId, name: existing.name });
303
336
  } catch (e) {
304
337
  return err(e instanceof Error ? e.message : "Failed to delete schedule");
305
338
  }
@@ -25,6 +25,7 @@ import {
25
25
  importRows,
26
26
  createImportRecord,
27
27
  } from "@/lib/tables/import";
28
+ import { createEnrichmentWorkflow } from "@/lib/tables/enrichment";
28
29
  import type { ColumnDef } from "@/lib/tables/types";
29
30
 
30
31
  export function tableTools(ctx: ToolContext) {
@@ -301,6 +302,76 @@ export function tableTools(ctx: ToolContext) {
301
302
  }
302
303
  ),
303
304
 
305
+ // ── Bulk row enrichment ──────────────────────────────────────────
306
+
307
+ defineTool(
308
+ "enrich_table",
309
+ `Bulk-enrich rows in a user table by running an agent task per row and writing the result back to a target column. Creates a row-driven loop workflow that fans out one task per matching row.
310
+
311
+ The prompt may reference row fields with {{row.fieldName}} placeholders — they are passed to the agent as JSON context. To skip a row at agent-time, return the literal string "NOT_FOUND". Already-populated rows (target column has a non-empty value) are skipped automatically for idempotency.
312
+
313
+ Returns the workflowId so the caller can poll status, plus the rowCount that will actually be processed.`,
314
+ {
315
+ tableId: z.string().describe("Table ID to enrich"),
316
+ prompt: z
317
+ .string()
318
+ .min(1)
319
+ .max(8192)
320
+ .describe(
321
+ "Per-row prompt template. Use {{row.fieldName}} to reference row fields. Instruct the agent to return NOT_FOUND when no value can be determined."
322
+ ),
323
+ targetColumn: z
324
+ .string()
325
+ .min(1)
326
+ .describe("Column name to write the agent's result into"),
327
+ filter: z
328
+ .object({
329
+ column: z.string(),
330
+ operator: z.enum([
331
+ "eq", "neq", "gt", "gte", "lt", "lte",
332
+ "contains", "starts_with", "in", "is_empty", "is_not_empty",
333
+ ]),
334
+ value: z
335
+ .union([z.string(), z.number(), z.boolean(), z.array(z.string())])
336
+ .optional(),
337
+ })
338
+ .optional()
339
+ .describe(
340
+ "Optional row filter — typically {column: targetColumn, operator: 'is_empty'} to enrich only blank cells"
341
+ ),
342
+ agentProfile: z
343
+ .string()
344
+ .optional()
345
+ .describe("Agent profile to use (defaults to 'sales-researcher')"),
346
+ projectId: z
347
+ .string()
348
+ .optional()
349
+ .describe("Project ID. Omit to use the active project."),
350
+ batchSize: z
351
+ .number()
352
+ .int()
353
+ .min(1)
354
+ .optional()
355
+ .describe("Maximum rows to process in this run (default 50, capped at 200)"),
356
+ },
357
+ async (args) => {
358
+ try {
359
+ const effectiveProjectId = args.projectId ?? ctx.projectId ?? undefined;
360
+ const result = await createEnrichmentWorkflow(args.tableId, {
361
+ prompt: args.prompt,
362
+ targetColumn: args.targetColumn,
363
+ filter: args.filter,
364
+ agentProfile: args.agentProfile,
365
+ projectId: effectiveProjectId,
366
+ batchSize: args.batchSize,
367
+ });
368
+ return ok(result);
369
+ } catch (e) {
370
+ return err(e instanceof Error ? e.message : "Failed to start enrichment");
371
+ }
372
+ }
373
+ ),
374
+
304
375
  // ── Creation operations ──────────────────────────────────────────
305
376
 
306
377
  defineTool(