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,328 @@
1
+ /**
2
+ * Table enrichment orchestration.
3
+ *
4
+ * V1 shipped a single-step loop primitive. V2 keeps the row fan-out model but
5
+ * promotes planning, typed contracts, and richer metadata so the same plan can
6
+ * drive preview, launch, runtime validation, and recent-run UX.
7
+ */
8
+
9
+ import { db } from "@/lib/db";
10
+ import { workflows } from "@/lib/db/schema";
11
+ import { desc, eq } from "drizzle-orm";
12
+ import { listRows, getTable } from "@/lib/data/tables";
13
+ import { executeWorkflow } from "@/lib/workflows/engine";
14
+ import type { WorkflowDefinition } from "@/lib/workflows/types";
15
+ import type { ColumnDef, FilterSpec } from "@/lib/tables/types";
16
+ import {
17
+ assertEnrichmentCompatibleColumn,
18
+ buildEnrichmentPlan,
19
+ type EnrichmentPlan,
20
+ type EnrichmentPromptMode,
21
+ type EnrichmentRow,
22
+ validateEnrichmentPlan,
23
+ wrapPromptWithOutputContract,
24
+ } from "@/lib/tables/enrichment-planner";
25
+
26
+ export type { EnrichmentPlan, EnrichmentPromptMode, EnrichmentRow };
27
+ export { wrapPromptWithOutputContract };
28
+
29
+ export interface GenerateEnrichmentInput {
30
+ rows: EnrichmentRow[];
31
+ tableId: string;
32
+ tableName?: string;
33
+ targetColumn: ColumnDef | string;
34
+ plan?: EnrichmentPlan;
35
+ prompt?: string;
36
+ agentProfile?: string;
37
+ itemVariable?: string;
38
+ }
39
+
40
+ /**
41
+ * Build a row-driven loop workflow definition. Each plan step becomes one
42
+ * inner step inside the row iteration. Only the final step carries the
43
+ * writeback postAction.
44
+ */
45
+ export function generateEnrichmentDefinition(
46
+ input: GenerateEnrichmentInput
47
+ ): WorkflowDefinition {
48
+ const targetColumn =
49
+ typeof input.targetColumn === "string"
50
+ ? {
51
+ name: input.targetColumn,
52
+ displayName: input.targetColumn,
53
+ dataType: "text" as const,
54
+ position: 0,
55
+ }
56
+ : input.targetColumn;
57
+ const plan =
58
+ input.plan ??
59
+ buildEnrichmentPlan({
60
+ targetColumn,
61
+ sampleRows: input.rows,
62
+ eligibleRowCount: input.rows.length,
63
+ promptMode: "custom",
64
+ prompt: input.prompt ?? "",
65
+ agentProfileOverride: input.agentProfile,
66
+ });
67
+ const itemVariable =
68
+ input.itemVariable && input.itemVariable.length > 0
69
+ ? input.itemVariable
70
+ : "row";
71
+
72
+ const items = input.rows.map((row) => ({
73
+ id: row.id,
74
+ ...row.data,
75
+ }));
76
+
77
+ return {
78
+ pattern: "loop",
79
+ steps: plan.steps.map((step, index) => ({
80
+ id: step.id,
81
+ name: step.name,
82
+ prompt: step.prompt,
83
+ agentProfile: step.agentProfile ?? plan.agentProfile,
84
+ postAction:
85
+ index === plan.steps.length - 1
86
+ ? {
87
+ type: "update_row" as const,
88
+ tableId: input.tableId,
89
+ rowId: `{{${itemVariable}.id}}`,
90
+ column: targetColumn.name,
91
+ }
92
+ : undefined,
93
+ })),
94
+ loopConfig: {
95
+ maxIterations: items.length,
96
+ items,
97
+ itemVariable,
98
+ },
99
+ metadata: {
100
+ enrichment: {
101
+ tableId: input.tableId,
102
+ tableName: input.tableName ?? input.tableId,
103
+ targetColumn: targetColumn.name,
104
+ targetColumnLabel: targetColumn.displayName,
105
+ promptMode: plan.promptMode,
106
+ strategy: plan.strategy,
107
+ agentProfile: plan.agentProfile,
108
+ eligibleRowCount: items.length,
109
+ targetContract: plan.targetContract,
110
+ },
111
+ },
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Idempotent skip: drop rows whose target column already has a non-empty,
117
+ * non-whitespace value. Missing keys, null, "", and whitespace-only all
118
+ * count as "not yet populated".
119
+ */
120
+ export function filterUnpopulatedRows(
121
+ rows: EnrichmentRow[],
122
+ targetColumn: string
123
+ ): EnrichmentRow[] {
124
+ return rows.filter((row) => {
125
+ const value = row.data[targetColumn];
126
+ if (value === undefined || value === null) return true;
127
+ if (typeof value !== "string") return false;
128
+ return value.trim() === "";
129
+ });
130
+ }
131
+
132
+ const DEFAULT_BATCH_SIZE = 50;
133
+ const MAX_BATCH_SIZE = 200;
134
+
135
+ interface EnrichmentPlanningParams {
136
+ targetColumn: string;
137
+ filter?: FilterSpec;
138
+ promptMode?: EnrichmentPromptMode;
139
+ prompt?: string;
140
+ agentProfile?: string;
141
+ agentProfileOverride?: string;
142
+ batchSize?: number;
143
+ }
144
+
145
+ export interface PreviewEnrichmentPlanParams extends EnrichmentPlanningParams {}
146
+
147
+ export interface CreateEnrichmentWorkflowParams extends EnrichmentPlanningParams {
148
+ projectId?: string | null;
149
+ itemVariable?: string;
150
+ workflowName?: string;
151
+ plan?: EnrichmentPlan;
152
+ }
153
+
154
+ export interface CreateEnrichmentWorkflowResult {
155
+ workflowId: string;
156
+ rowCount: number;
157
+ }
158
+
159
+ export interface EnrichmentRunSummary {
160
+ workflowId: string;
161
+ name: string;
162
+ status: string;
163
+ updatedAt: string;
164
+ targetColumn: string;
165
+ targetColumnLabel: string;
166
+ rowCount: number;
167
+ strategy: EnrichmentPlan["strategy"];
168
+ promptMode: EnrichmentPromptMode;
169
+ }
170
+
171
+ export async function previewEnrichmentPlan(
172
+ tableId: string,
173
+ params: PreviewEnrichmentPlanParams
174
+ ): Promise<EnrichmentPlan> {
175
+ const prepared = await prepareEnrichment(tableId, params);
176
+ return buildEnrichmentPlan({
177
+ targetColumn: prepared.targetColumn,
178
+ sampleRows: prepared.eligibleRows,
179
+ eligibleRowCount: prepared.eligibleRows.length,
180
+ promptMode: resolvePromptMode(params.promptMode, params.prompt),
181
+ prompt: params.prompt,
182
+ agentProfileOverride: params.agentProfileOverride ?? params.agentProfile,
183
+ filter: params.filter,
184
+ });
185
+ }
186
+
187
+ export async function createEnrichmentWorkflow(
188
+ tableId: string,
189
+ params: CreateEnrichmentWorkflowParams
190
+ ): Promise<CreateEnrichmentWorkflowResult> {
191
+ const prepared = await prepareEnrichment(tableId, params);
192
+
193
+ const plan =
194
+ params.plan ??
195
+ buildEnrichmentPlan({
196
+ targetColumn: prepared.targetColumn,
197
+ sampleRows: prepared.eligibleRows,
198
+ eligibleRowCount: prepared.eligibleRows.length,
199
+ promptMode: resolvePromptMode(params.promptMode, params.prompt),
200
+ prompt: params.prompt,
201
+ agentProfileOverride: params.agentProfileOverride ?? params.agentProfile,
202
+ filter: params.filter,
203
+ });
204
+
205
+ validateEnrichmentPlan(plan, prepared.targetColumn);
206
+
207
+ const definition = generateEnrichmentDefinition({
208
+ rows: prepared.eligibleRows,
209
+ tableId,
210
+ tableName: prepared.table.name,
211
+ targetColumn: prepared.targetColumn,
212
+ plan,
213
+ itemVariable: params.itemVariable,
214
+ });
215
+
216
+ const workflowId = crypto.randomUUID();
217
+ const now = new Date();
218
+ const name =
219
+ params.workflowName?.trim() ||
220
+ `Enrich ${prepared.table.name} · ${prepared.targetColumn.displayName}`;
221
+
222
+ await db.insert(workflows).values({
223
+ id: workflowId,
224
+ name,
225
+ projectId: params.projectId ?? prepared.table.projectId ?? null,
226
+ definition: JSON.stringify(definition),
227
+ status: "draft",
228
+ createdAt: now,
229
+ updatedAt: now,
230
+ });
231
+
232
+ executeWorkflow(workflowId).catch((err) => {
233
+ console.error(`[enrichment] executeWorkflow failed for ${workflowId}:`, err);
234
+ });
235
+
236
+ await db.select().from(workflows).where(eq(workflows.id, workflowId));
237
+
238
+ return {
239
+ workflowId,
240
+ rowCount: prepared.eligibleRows.length,
241
+ };
242
+ }
243
+
244
+ export async function listRecentEnrichmentRuns(
245
+ tableId: string,
246
+ limit: number = 5
247
+ ): Promise<EnrichmentRunSummary[]> {
248
+ const rows = await db
249
+ .select()
250
+ .from(workflows)
251
+ .orderBy(desc(workflows.updatedAt));
252
+
253
+ const runs = rows
254
+ .map((workflow): EnrichmentRunSummary | null => {
255
+ try {
256
+ const definition = JSON.parse(workflow.definition) as WorkflowDefinition;
257
+ const meta = definition.metadata?.enrichment;
258
+ if (!meta || meta.tableId !== tableId) return null;
259
+ return {
260
+ workflowId: workflow.id,
261
+ name: workflow.name,
262
+ status: workflow.status,
263
+ updatedAt: workflow.updatedAt.toISOString(),
264
+ targetColumn: meta.targetColumn,
265
+ targetColumnLabel: meta.targetColumnLabel,
266
+ rowCount: meta.eligibleRowCount,
267
+ strategy: meta.strategy,
268
+ promptMode: meta.promptMode,
269
+ } satisfies EnrichmentRunSummary;
270
+ } catch {
271
+ return null;
272
+ }
273
+ });
274
+
275
+ return runs
276
+ .filter((run): run is EnrichmentRunSummary => run !== null)
277
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
278
+ .slice(0, limit);
279
+ }
280
+
281
+ async function prepareEnrichment(
282
+ tableId: string,
283
+ params: EnrichmentPlanningParams
284
+ ): Promise<{
285
+ table: NonNullable<Awaited<ReturnType<typeof getTable>>>;
286
+ targetColumn: ColumnDef;
287
+ eligibleRows: EnrichmentRow[];
288
+ }> {
289
+ const table = await getTable(tableId);
290
+ if (!table) {
291
+ throw new Error(`Table ${tableId} not found`);
292
+ }
293
+
294
+ const columnSchema = JSON.parse(table.columnSchema) as ColumnDef[];
295
+ const targetColumn = columnSchema.find((column) => column.name === params.targetColumn);
296
+ if (!targetColumn) {
297
+ throw new Error(
298
+ `Column "${params.targetColumn}" does not exist on table ${tableId}`
299
+ );
300
+ }
301
+ assertEnrichmentCompatibleColumn(targetColumn);
302
+
303
+ const batchSize = Math.min(params.batchSize ?? DEFAULT_BATCH_SIZE, MAX_BATCH_SIZE);
304
+ const rawRows = await listRows(tableId, {
305
+ filters: params.filter ? [params.filter] : undefined,
306
+ limit: batchSize,
307
+ });
308
+
309
+ const rows: EnrichmentRow[] = rawRows.map((row) => ({
310
+ id: row.id,
311
+ data: JSON.parse(row.data) as Record<string, unknown>,
312
+ }));
313
+
314
+ const eligibleRows = filterUnpopulatedRows(rows, targetColumn.name);
315
+ return {
316
+ table,
317
+ targetColumn,
318
+ eligibleRows,
319
+ };
320
+ }
321
+
322
+ function resolvePromptMode(
323
+ promptMode: EnrichmentPromptMode | undefined,
324
+ prompt: string | undefined
325
+ ): EnrichmentPromptMode {
326
+ if (promptMode) return promptMode;
327
+ return prompt?.trim() ? "custom" : "auto";
328
+ }
@@ -77,9 +77,12 @@ function buildFilterClause(
77
77
  return sql`${col} IN (${sql.join(placeholders, sql`, `)})`;
78
78
  }
79
79
  case "is_empty":
80
- return sql`(${col} IS NULL OR ${col} = '')`;
80
+ // Treat whitespace-only as empty so the filter agrees with the
81
+ // server-side `filterUnpopulatedRows` / `shouldSkipPostActionValue`
82
+ // semantics used by bulk row enrichment.
83
+ return sql`(${col} IS NULL OR TRIM(${col}) = '')`;
81
84
  case "is_not_empty":
82
- return sql`(${col} IS NOT NULL AND ${col} != '')`;
85
+ return sql`(${col} IS NOT NULL AND TRIM(${col}) != '')`;
83
86
  default:
84
87
  throw new Error(`Unknown filter operator: ${filter.operator}`);
85
88
  }
@@ -99,9 +99,10 @@ function matchesCondition(
99
99
  case "in":
100
100
  return Array.isArray(condition.value) && condition.value.includes(strValue);
101
101
  case "is_empty":
102
- return value == null || strValue === "";
102
+ // Whitespace-only counts as empty (matches SQL `is_empty` operator).
103
+ return value == null || strValue.trim() === "";
103
104
  case "is_not_empty":
104
- return value != null && strValue !== "";
105
+ return value != null && strValue.trim() !== "";
105
106
  default:
106
107
  return true;
107
108
  }
@@ -0,0 +1,71 @@
1
+ // Shared theme utilities. Server reads the `stagent-theme` cookie and renders
2
+ // <html className="dark"> directly, so we no longer need a pre-hydration <script>
3
+ // bootstrap to prevent FOUC. Every client-side toggle must keep the cookie in
4
+ // sync so the next SSR matches.
5
+ //
6
+ // Previously a next/script <Script strategy="beforeInteractive"> injected the
7
+ // theme, but in Next.js 16 + React 19 any <script> element in the component
8
+ // tree fires a "script tag inside React component" dev warning.
9
+
10
+ export type ResolvedTheme = "light" | "dark";
11
+
12
+ export const THEME_COOKIE = "stagent-theme";
13
+ export const DEFAULT_THEME: ResolvedTheme = "light";
14
+
15
+ export function isResolvedTheme(value: unknown): value is ResolvedTheme {
16
+ return value === "light" || value === "dark";
17
+ }
18
+
19
+ /**
20
+ * Client-only: apply a theme everywhere it needs to land — DOM, localStorage,
21
+ * and cookie. Always use this instead of setting them individually so we can't
22
+ * drift between storage locations.
23
+ */
24
+ export function applyTheme(theme: ResolvedTheme): void {
25
+ if (typeof document === "undefined") return;
26
+ const root = document.documentElement;
27
+ root.classList.toggle("dark", theme === "dark");
28
+ root.dataset.theme = theme;
29
+ root.style.colorScheme = theme;
30
+ root.style.backgroundColor =
31
+ theme === "dark" ? "oklch(0.14 0.02 250)" : "oklch(0.985 0.004 250)";
32
+ try {
33
+ localStorage.setItem(THEME_COOKIE, theme);
34
+ } catch {
35
+ /* storage may be unavailable (private mode, quota) */
36
+ }
37
+ document.cookie = `${THEME_COOKIE}=${theme};path=/;max-age=31536000;SameSite=Lax`;
38
+ }
39
+
40
+ /**
41
+ * Client-only: flip the current theme and apply. Returns the new theme.
42
+ */
43
+ export function toggleTheme(): ResolvedTheme {
44
+ const current: ResolvedTheme = document.documentElement.classList.contains(
45
+ "dark"
46
+ )
47
+ ? "dark"
48
+ : "light";
49
+ const next: ResolvedTheme = current === "dark" ? "light" : "dark";
50
+ applyTheme(next);
51
+ return next;
52
+ }
53
+
54
+ /**
55
+ * Client-only: resolve the user's preferred theme from localStorage, falling
56
+ * back to the cookie, then the default. Used by the toggle button to seed its
57
+ * `dark` state on mount.
58
+ */
59
+ export function readClientTheme(): ResolvedTheme {
60
+ if (typeof document === "undefined") return DEFAULT_THEME;
61
+ try {
62
+ const stored = localStorage.getItem(THEME_COOKIE);
63
+ if (isResolvedTheme(stored)) return stored;
64
+ } catch {
65
+ /* ignore */
66
+ }
67
+ const cookieMatch = document.cookie.match(/(?:^|;\s*)stagent-theme=([^;]+)/);
68
+ const cookieValue = cookieMatch?.[1];
69
+ if (isResolvedTheme(cookieValue)) return cookieValue;
70
+ return DEFAULT_THEME;
71
+ }
@@ -19,7 +19,8 @@ export type UsageActivityType =
19
19
  | "pattern_extraction"
20
20
  | "context_summarization"
21
21
  | "chat_turn"
22
- | "profile_assist";
22
+ | "profile_assist"
23
+ | "manual_force_bypass";
23
24
 
24
25
  export type UsageLedgerStatus =
25
26
  | "completed"
@@ -249,23 +250,6 @@ export async function recordUsageLedgerEntry(input: UsageLedgerWriteInput) {
249
250
 
250
251
  await db.insert(usageLedger).values(row);
251
252
 
252
- // Queue telemetry event (opt-in, fire-and-forget)
253
- try {
254
- const { queueTelemetryEvent } = await import("@/lib/telemetry/queue");
255
- queueTelemetryEvent({
256
- runtimeId: input.runtimeId,
257
- providerId: input.providerId,
258
- modelId: input.modelId ?? "unknown",
259
- activityType: input.activityType,
260
- outcomeStatus: status,
261
- tokenCount: normalizedTotalTokens ?? undefined,
262
- costMicros: resolvedCostMicros ?? undefined,
263
- durationMs: input.finishedAt.getTime() - input.startedAt.getTime(),
264
- });
265
- } catch {
266
- // Telemetry is non-critical
267
- }
268
-
269
253
  return row;
270
254
  }
271
255
 
@@ -0,0 +1,106 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { extractKeywords, jaccard, tagOverlap, STOP_WORDS } from "../similarity";
3
+
4
+ describe("similarity.extractKeywords", () => {
5
+ it("returns an empty set for empty input", () => {
6
+ expect(extractKeywords("")).toEqual(new Set());
7
+ });
8
+
9
+ it("lowercases and strips punctuation", () => {
10
+ const result = extractKeywords("Research Customer Feedback!");
11
+ expect(result.has("research")).toBe(true);
12
+ expect(result.has("customer")).toBe(true);
13
+ expect(result.has("feedback")).toBe(true);
14
+ });
15
+
16
+ it("excludes stop words", () => {
17
+ const result = extractKeywords("the research about customer and the feedback");
18
+ expect(result.has("the")).toBe(false);
19
+ expect(result.has("about")).toBe(false);
20
+ expect(result.has("research")).toBe(true);
21
+ });
22
+
23
+ it("filters tokens shorter than 4 chars", () => {
24
+ const result = extractKeywords("go do it now with research");
25
+ expect(result.has("go")).toBe(false);
26
+ expect(result.has("do")).toBe(false);
27
+ expect(result.has("it")).toBe(false);
28
+ expect(result.has("now")).toBe(false);
29
+ expect(result.has("research")).toBe(true);
30
+ });
31
+
32
+ it("filters tokens longer than 29 chars", () => {
33
+ const longWord = "a".repeat(30);
34
+ const result = extractKeywords(`research ${longWord} customer`);
35
+ expect(result.has(longWord)).toBe(false);
36
+ expect(result.has("research")).toBe(true);
37
+ });
38
+
39
+ it("respects the limit parameter", () => {
40
+ const text = "alpha bravo charlie delta echo foxtrot golf hotel india juliet";
41
+ const result = extractKeywords(text, 3);
42
+ expect(result.size).toBe(3);
43
+ });
44
+
45
+ it("orders by frequency before applying the limit", () => {
46
+ // 'research' appears 3 times, 'customer' 2 times, 'feedback' 1 time.
47
+ const text = "research customer feedback research customer research";
48
+ const result = extractKeywords(text, 2);
49
+ expect(result.has("research")).toBe(true);
50
+ expect(result.has("customer")).toBe(true);
51
+ expect(result.has("feedback")).toBe(false);
52
+ });
53
+
54
+ it("preserves hyphenated tokens", () => {
55
+ const result = extractKeywords("multi-agent workflow");
56
+ expect(result.has("multi-agent")).toBe(true);
57
+ });
58
+ });
59
+
60
+ describe("similarity.jaccard", () => {
61
+ it("returns 0 for two empty sets", () => {
62
+ expect(jaccard(new Set(), new Set())).toBe(0);
63
+ });
64
+
65
+ it("returns 0 for disjoint sets", () => {
66
+ expect(jaccard(new Set(["a", "b"]), new Set(["c", "d"]))).toBe(0);
67
+ });
68
+
69
+ it("returns 1 for identical sets", () => {
70
+ expect(jaccard(new Set(["a", "b"]), new Set(["a", "b"]))).toBe(1);
71
+ });
72
+
73
+ it("computes intersection / union correctly", () => {
74
+ // {a,b,c} vs {b,c,d} → intersection=2, union=4 → 0.5
75
+ expect(jaccard(new Set(["a", "b", "c"]), new Set(["b", "c", "d"]))).toBe(0.5);
76
+ });
77
+
78
+ it("returns 0 when one set is empty and other is not", () => {
79
+ expect(jaccard(new Set(), new Set(["a"]))).toBe(0);
80
+ });
81
+ });
82
+
83
+ describe("similarity.tagOverlap", () => {
84
+ it("returns 0 when candidate has no tags", () => {
85
+ expect(tagOverlap([], ["a", "b"])).toBe(0);
86
+ });
87
+
88
+ it("is case-insensitive", () => {
89
+ expect(tagOverlap(["Research"], ["research"])).toBe(1);
90
+ });
91
+
92
+ it("returns fraction of candidate tags present in existing", () => {
93
+ expect(tagOverlap(["a", "b", "c"], ["a", "b", "z"])).toBeCloseTo(2 / 3);
94
+ });
95
+
96
+ it("returns 1 when all candidate tags match", () => {
97
+ expect(tagOverlap(["a", "b"], ["a", "b", "c"])).toBe(1);
98
+ });
99
+ });
100
+
101
+ describe("similarity.STOP_WORDS", () => {
102
+ it("exposes a non-empty stop word set", () => {
103
+ expect(STOP_WORDS.size).toBeGreaterThan(0);
104
+ expect(STOP_WORDS.has("the")).toBe(true);
105
+ });
106
+ });
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Shared similarity utilities.
3
+ *
4
+ * Small, dependency-free helpers for fuzzy matching: keyword extraction,
5
+ * Jaccard similarity, and tag overlap. Used by the profile import dedup
6
+ * engine (`src/lib/import/dedup.ts`) and the chat workflow tool dedup
7
+ * check (`src/lib/chat/tools/workflow-tools.ts`).
8
+ *
9
+ * Extracted into a shared module so both callers use the same keyword
10
+ * normalization and comparison math — if one grows, the other benefits.
11
+ */
12
+
13
+ /** Common stop words to exclude from keyword extraction. */
14
+ export const STOP_WORDS = new Set([
15
+ "the", "and", "for", "are", "but", "not", "you", "all", "can", "had",
16
+ "her", "was", "one", "our", "out", "has", "have", "that", "this", "with",
17
+ "from", "they", "been", "will", "each", "make", "like", "into", "them",
18
+ "some", "when", "what", "your", "should", "would", "could", "about",
19
+ "which", "their", "other", "than", "then", "more", "also",
20
+ "only", "must", "does", "here", "just", "over", "such", "after",
21
+ "before", "between", "through", "where", "these", "those", "being",
22
+ "using", "ensure", "every", "following", "include",
23
+ ]);
24
+
25
+ /**
26
+ * Extract meaningful keywords from text.
27
+ *
28
+ * Lowercases, strips non-alphanumeric, filters out stop words and tokens
29
+ * outside a reasonable length window, then returns the top-N most frequent
30
+ * terms as a Set.
31
+ */
32
+ export function extractKeywords(text: string, limit = 20): Set<string> {
33
+ const words = text
34
+ .toLowerCase()
35
+ .replace(/[^a-z0-9\s-]/g, " ")
36
+ .split(/\s+/)
37
+ .filter((w) => w.length > 3 && w.length < 30 && !STOP_WORDS.has(w));
38
+
39
+ const freq = new Map<string, number>();
40
+ for (const word of words) {
41
+ freq.set(word, (freq.get(word) ?? 0) + 1);
42
+ }
43
+
44
+ const sorted = Array.from(freq.entries())
45
+ .sort((a, b) => b[1] - a[1])
46
+ .slice(0, limit)
47
+ .map(([word]) => word);
48
+
49
+ return new Set(sorted);
50
+ }
51
+
52
+ /**
53
+ * Jaccard similarity between two sets — |A ∩ B| / |A ∪ B|.
54
+ *
55
+ * Returns 0 when both sets are empty (a reasonable default for "nothing to
56
+ * compare" rather than the mathematical indeterminate form).
57
+ */
58
+ export function jaccard(a: Set<string>, b: Set<string>): number {
59
+ if (a.size === 0 && b.size === 0) return 0;
60
+ let intersection = 0;
61
+ for (const item of a) {
62
+ if (b.has(item)) intersection++;
63
+ }
64
+ const union = a.size + b.size - intersection;
65
+ return union === 0 ? 0 : intersection / union;
66
+ }
67
+
68
+ /**
69
+ * Tag overlap ratio — how many of the candidate's tags match the existing
70
+ * set, normalized by candidate size. Case-insensitive.
71
+ */
72
+ export function tagOverlap(candidateTags: string[], existingTags: string[]): number {
73
+ if (candidateTags.length === 0) return 0;
74
+ const existingSet = new Set(existingTags.map((t) => t.toLowerCase()));
75
+ const matches = candidateTags.filter((t) => existingSet.has(t.toLowerCase()));
76
+ return matches.length / candidateTags.length;
77
+ }
@@ -44,3 +44,27 @@ export function formatTime(date: string | Date): string {
44
44
  second: "2-digit",
45
45
  });
46
46
  }
47
+
48
+ /**
49
+ * Compact date-time for space-constrained surfaces (kanban cards, bento cells).
50
+ * Today: "14:23" | This week: "Mon 14:23" | This year: "Apr 12, 14:23" | Older: "Apr 12, 2025"
51
+ */
52
+ export function formatCompactDateTime(date: string | Date): string {
53
+ const d = typeof date === "string" ? new Date(date) : date;
54
+ const now = new Date();
55
+ const diff = now.getTime() - d.getTime();
56
+ const time = d.toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" });
57
+
58
+ if (d.toDateString() === now.toDateString()) return time;
59
+
60
+ if (diff > 0 && diff < 7 * DAY) {
61
+ const weekday = d.toLocaleDateString("en-US", { weekday: "short" });
62
+ return `${weekday} ${time}`;
63
+ }
64
+
65
+ const monthDay = d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
66
+
67
+ if (d.getFullYear() === now.getFullYear()) return `${monthDay}, ${time}`;
68
+
69
+ return `${monthDay}, ${d.getFullYear()}`;
70
+ }
@@ -40,3 +40,15 @@ export function getStagentLogsDir(): string {
40
40
  export function getStagentDocumentsDir(): string {
41
41
  return join(getStagentDataDir(), "documents");
42
42
  }
43
+
44
+ export function getStagentCodexDir(): string {
45
+ return join(getStagentDataDir(), "codex");
46
+ }
47
+
48
+ export function getStagentCodexConfigPath(): string {
49
+ return join(getStagentCodexDir(), "config.toml");
50
+ }
51
+
52
+ export function getStagentCodexAuthPath(): string {
53
+ return join(getStagentCodexDir(), "auth.json");
54
+ }