stagent 0.9.6 → 0.11.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 (396) hide show
  1. package/README.md +20 -44
  2. package/dist/cli.js +66 -18
  3. package/docs/.coverage-gaps.json +144 -56
  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 +20 -2
  12. package/docs/features/schedules.md +32 -4
  13. package/docs/features/settings.md +28 -5
  14. package/docs/features/shared-components.md +7 -3
  15. package/docs/features/tables.md +11 -2
  16. package/docs/features/tool-permissions.md +6 -2
  17. package/docs/features/workflows.md +14 -4
  18. package/docs/index.md +1 -1
  19. package/docs/journeys/developer.md +39 -2
  20. package/docs/journeys/personal-use.md +32 -8
  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 +1691 -0
  25. package/docs/superpowers/plans/2026-04-08-schedule-orchestration.md +2983 -0
  26. package/docs/superpowers/plans/2026-04-11-schedule-maxturns-api-control.md +551 -0
  27. package/docs/superpowers/plans/2026-04-11-task-create-profile-validation.md +864 -0
  28. package/docs/superpowers/plans/2026-04-11-task-runtime-stagent-mcp-injection.md +739 -0
  29. package/docs/superpowers/plans/2026-04-14-chat-command-namespace-refactor.md +1390 -0
  30. package/docs/superpowers/plans/2026-04-14-chat-environment-integration.md +1561 -0
  31. package/docs/superpowers/plans/2026-04-14-chat-polish-bundle-v1.md +1219 -0
  32. package/docs/superpowers/plans/2026-04-14-chat-session-persistence-provider-closeout.md +399 -0
  33. package/docs/superpowers/specs/2026-04-08-chat-sse-resilience-hotfix-design.md +201 -0
  34. package/docs/superpowers/specs/2026-04-08-schedule-orchestration-design.md +371 -0
  35. package/docs/superpowers/specs/2026-04-08-swarm-visibility-design.md +213 -0
  36. package/next.config.mjs +1 -0
  37. package/package.json +3 -2
  38. package/src/__tests__/instrumentation-smoke.test.ts +15 -0
  39. package/src/app/analytics/page.tsx +1 -21
  40. package/src/app/api/chat/conversations/[id]/messages/route.ts +22 -1
  41. package/src/app/api/chat/conversations/[id]/skills/__tests__/activate.test.ts +141 -0
  42. package/src/app/api/chat/conversations/[id]/skills/activate/route.ts +74 -0
  43. package/src/app/api/chat/conversations/[id]/skills/deactivate/route.ts +33 -0
  44. package/src/app/api/chat/export/route.ts +52 -0
  45. package/src/app/api/chat/files/search/route.ts +50 -0
  46. package/src/app/api/diagnostics/chat-streams/route.ts +65 -0
  47. package/src/app/api/environment/rescan-if-stale/__tests__/route.test.ts +45 -0
  48. package/src/app/api/environment/rescan-if-stale/route.ts +23 -0
  49. package/src/app/api/environment/skills/route.ts +13 -0
  50. package/src/app/api/instance/config/route.ts +41 -0
  51. package/src/app/api/instance/init/route.ts +34 -0
  52. package/src/app/api/instance/upgrade/check/route.ts +26 -0
  53. package/src/app/api/instance/upgrade/route.ts +96 -0
  54. package/src/app/api/instance/upgrade/status/route.ts +35 -0
  55. package/src/app/api/memory/route.ts +0 -11
  56. package/src/app/api/notifications/route.ts +4 -2
  57. package/src/app/api/projects/[id]/route.ts +5 -155
  58. package/src/app/api/projects/__tests__/delete-project.test.ts +10 -19
  59. package/src/app/api/schedules/[id]/execute/route.ts +111 -0
  60. package/src/app/api/schedules/[id]/route.ts +9 -1
  61. package/src/app/api/schedules/__tests__/execute-route.test.ts +118 -0
  62. package/src/app/api/schedules/route.ts +3 -12
  63. package/src/app/api/settings/chat/pins/route.ts +94 -0
  64. package/src/app/api/settings/chat/saved-searches/__tests__/route.test.ts +119 -0
  65. package/src/app/api/settings/chat/saved-searches/route.ts +79 -0
  66. package/src/app/api/settings/environment/route.ts +26 -0
  67. package/src/app/api/settings/openai/login/route.ts +22 -0
  68. package/src/app/api/settings/openai/logout/route.ts +7 -0
  69. package/src/app/api/settings/openai/route.ts +21 -1
  70. package/src/app/api/settings/providers/route.ts +35 -8
  71. package/src/app/api/tables/[id]/enrich/__tests__/route.test.ts +153 -0
  72. package/src/app/api/tables/[id]/enrich/plan/route.ts +98 -0
  73. package/src/app/api/tables/[id]/enrich/route.ts +147 -0
  74. package/src/app/api/tables/[id]/enrich/runs/route.ts +25 -0
  75. package/src/app/api/tasks/[id]/execute/route.ts +52 -33
  76. package/src/app/api/tasks/[id]/respond/route.ts +31 -15
  77. package/src/app/api/tasks/[id]/resume/route.ts +24 -3
  78. package/src/app/api/workflows/[id]/resume/route.ts +59 -0
  79. package/src/app/api/workflows/[id]/status/route.ts +22 -8
  80. package/src/app/api/workspace/context/route.ts +2 -0
  81. package/src/app/api/workspace/fix-data-dir/route.ts +81 -0
  82. package/src/app/chat/page.tsx +11 -0
  83. package/src/app/documents/page.tsx +4 -1
  84. package/src/app/inbox/page.tsx +12 -5
  85. package/src/app/layout.tsx +42 -21
  86. package/src/app/page.tsx +0 -2
  87. package/src/app/settings/page.tsx +8 -9
  88. package/src/components/chat/__tests__/capability-banner.test.tsx +38 -0
  89. package/src/components/chat/__tests__/chat-session-provider.test.tsx +573 -0
  90. package/src/components/chat/__tests__/skill-row.test.tsx +91 -0
  91. package/src/components/chat/capability-banner.tsx +68 -0
  92. package/src/components/chat/chat-command-popover.tsx +670 -49
  93. package/src/components/chat/chat-input.tsx +104 -10
  94. package/src/components/chat/chat-message.tsx +12 -3
  95. package/src/components/chat/chat-session-provider.tsx +790 -0
  96. package/src/components/chat/chat-shell.tsx +151 -401
  97. package/src/components/chat/command-tab-bar.tsx +68 -0
  98. package/src/components/chat/conversation-template-picker.tsx +421 -0
  99. package/src/components/chat/help-dialog.tsx +39 -0
  100. package/src/components/chat/skill-composition-conflict-dialog.tsx +96 -0
  101. package/src/components/chat/skill-row.tsx +147 -0
  102. package/src/components/documents/document-browser.tsx +37 -19
  103. package/src/components/instance/__tests__/instance-section.test.tsx +125 -0
  104. package/src/components/instance/instance-section.tsx +382 -0
  105. package/src/components/instance/upgrade-badge.tsx +219 -0
  106. package/src/components/notifications/__tests__/batch-proposal-review.test.tsx +95 -0
  107. package/src/components/notifications/__tests__/notification-item.test.tsx +106 -0
  108. package/src/components/notifications/__tests__/permission-response-actions.test.tsx +70 -0
  109. package/src/components/notifications/batch-proposal-review.tsx +20 -5
  110. package/src/components/notifications/inbox-list.tsx +11 -2
  111. package/src/components/notifications/notification-item.tsx +56 -2
  112. package/src/components/notifications/pending-approval-host.tsx +56 -37
  113. package/src/components/notifications/permission-response-actions.tsx +155 -1
  114. package/src/components/schedules/schedule-create-sheet.tsx +19 -1
  115. package/src/components/schedules/schedule-edit-sheet.tsx +20 -1
  116. package/src/components/schedules/schedule-form.tsx +31 -0
  117. package/src/components/settings/__tests__/providers-runtimes-section.test.tsx +149 -0
  118. package/src/components/settings/auth-method-selector.tsx +19 -4
  119. package/src/components/settings/auth-status-badge.tsx +28 -3
  120. package/src/components/settings/environment-section.tsx +102 -0
  121. package/src/components/settings/openai-chatgpt-auth-control.tsx +278 -0
  122. package/src/components/settings/openai-runtime-section.tsx +7 -1
  123. package/src/components/settings/providers-runtimes-section.tsx +138 -19
  124. package/src/components/shared/__tests__/filter-hint.test.tsx +40 -0
  125. package/src/components/shared/__tests__/saved-searches-manager.test.tsx +147 -0
  126. package/src/components/shared/app-sidebar.tsx +4 -3
  127. package/src/components/shared/command-palette.tsx +266 -7
  128. package/src/components/shared/filter-hint.tsx +70 -0
  129. package/src/components/shared/filter-input.tsx +59 -0
  130. package/src/components/shared/saved-searches-manager.tsx +199 -0
  131. package/src/components/shared/theme-toggle.tsx +5 -24
  132. package/src/components/shared/workspace-indicator.tsx +61 -2
  133. package/src/components/tables/__tests__/table-enrichment-sheet.test.tsx +130 -0
  134. package/src/components/tables/table-create-sheet.tsx +4 -0
  135. package/src/components/tables/table-enrichment-runs.tsx +103 -0
  136. package/src/components/tables/table-enrichment-sheet.tsx +538 -0
  137. package/src/components/tables/table-spreadsheet.tsx +29 -5
  138. package/src/components/tables/table-toolbar.tsx +10 -1
  139. package/src/components/tasks/kanban-board.tsx +1 -0
  140. package/src/components/tasks/kanban-column.tsx +53 -14
  141. package/src/components/tasks/task-bento-grid.tsx +31 -2
  142. package/src/components/tasks/task-card.tsx +29 -3
  143. package/src/components/tasks/task-chip-bar.tsx +54 -1
  144. package/src/components/tasks/task-result-renderer.tsx +1 -1
  145. package/src/components/workflows/delay-step-body.tsx +109 -0
  146. package/src/components/workflows/hooks/use-workflow-status.ts +50 -0
  147. package/src/components/workflows/loop-status-view.tsx +1 -1
  148. package/src/components/workflows/shared/step-result.tsx +78 -0
  149. package/src/components/workflows/shared/workflow-header.tsx +141 -0
  150. package/src/components/workflows/shared/workflow-loading-skeleton.tsx +36 -0
  151. package/src/components/workflows/swarm-dashboard.tsx +2 -15
  152. package/src/components/workflows/views/loop-pattern-view.tsx +137 -0
  153. package/src/components/workflows/views/sequence-pattern-view.tsx +511 -0
  154. package/src/components/workflows/workflow-form-view.tsx +133 -16
  155. package/src/components/workflows/workflow-status-view.tsx +30 -740
  156. package/src/hooks/__tests__/use-chat-autocomplete-tabs.test.ts +47 -0
  157. package/src/hooks/__tests__/use-saved-searches.test.ts +70 -0
  158. package/src/hooks/use-active-skills.ts +110 -0
  159. package/src/hooks/use-chat-autocomplete.ts +120 -7
  160. package/src/hooks/use-enriched-skills.ts +19 -0
  161. package/src/hooks/use-pinned-entries.ts +104 -0
  162. package/src/hooks/use-recent-user-messages.ts +19 -0
  163. package/src/hooks/use-saved-searches.ts +142 -0
  164. package/src/instrumentation-node.ts +94 -0
  165. package/src/instrumentation.ts +4 -48
  166. package/src/lib/agents/__tests__/claude-agent-sdk-options.test.ts +56 -0
  167. package/src/lib/agents/__tests__/claude-agent.test.ts +212 -0
  168. package/src/lib/agents/__tests__/execution-manager.test.ts +1 -27
  169. package/src/lib/agents/__tests__/failure-reason.test.ts +68 -0
  170. package/src/lib/agents/__tests__/learned-context.test.ts +0 -11
  171. package/src/lib/agents/__tests__/learning-session.test.ts +158 -0
  172. package/src/lib/agents/__tests__/pattern-extractor.test.ts +48 -0
  173. package/src/lib/agents/__tests__/task-dispatch.test.ts +166 -0
  174. package/src/lib/agents/__tests__/tool-permissions.test.ts +60 -0
  175. package/src/lib/agents/claude-agent.ts +217 -21
  176. package/src/lib/agents/execution-manager.ts +0 -35
  177. package/src/lib/agents/handoff/bus.ts +2 -2
  178. package/src/lib/agents/learned-context.ts +0 -12
  179. package/src/lib/agents/learning-session.ts +18 -5
  180. package/src/lib/agents/profiles/__tests__/list-fused-profiles.test.ts +110 -0
  181. package/src/lib/agents/profiles/__tests__/registry.test.ts +53 -4
  182. package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +97 -0
  183. package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +36 -0
  184. package/src/lib/agents/profiles/list-fused-profiles.ts +104 -0
  185. package/src/lib/agents/profiles/registry.ts +18 -0
  186. package/src/lib/agents/profiles/types.ts +7 -1
  187. package/src/lib/agents/router.ts +3 -6
  188. package/src/lib/agents/runtime/__tests__/catalog.test.ts +130 -0
  189. package/src/lib/agents/runtime/__tests__/execution-target.test.ts +183 -0
  190. package/src/lib/agents/runtime/__tests__/openai-codex-auth.test.ts +118 -0
  191. package/src/lib/agents/runtime/anthropic-direct.ts +8 -0
  192. package/src/lib/agents/runtime/catalog.ts +121 -0
  193. package/src/lib/agents/runtime/claude-sdk.ts +32 -0
  194. package/src/lib/agents/runtime/codex-app-server-client.ts +11 -5
  195. package/src/lib/agents/runtime/execution-target.ts +456 -0
  196. package/src/lib/agents/runtime/index.ts +4 -0
  197. package/src/lib/agents/runtime/launch-failure.ts +101 -0
  198. package/src/lib/agents/runtime/openai-codex-auth.ts +389 -0
  199. package/src/lib/agents/runtime/openai-codex.ts +64 -60
  200. package/src/lib/agents/runtime/openai-direct.ts +8 -0
  201. package/src/lib/agents/runtime/types.ts +8 -0
  202. package/src/lib/agents/task-dispatch.ts +220 -0
  203. package/src/lib/agents/tool-permissions.ts +16 -1
  204. package/src/lib/book/chapter-mapping.ts +11 -0
  205. package/src/lib/book/content.ts +10 -0
  206. package/src/lib/chat/__tests__/active-skill-injection.test.ts +261 -0
  207. package/src/lib/chat/__tests__/active-streams.test.ts +49 -0
  208. package/src/lib/chat/__tests__/clean-filter-input.test.ts +68 -0
  209. package/src/lib/chat/__tests__/command-tabs.test.ts +68 -0
  210. package/src/lib/chat/__tests__/context-builder-files.test.ts +112 -0
  211. package/src/lib/chat/__tests__/dismissals.test.ts +65 -0
  212. package/src/lib/chat/__tests__/engine-sdk-options.test.ts +117 -0
  213. package/src/lib/chat/__tests__/finalize-safety-net.test.ts +139 -0
  214. package/src/lib/chat/__tests__/reconcile.test.ts +137 -0
  215. package/src/lib/chat/__tests__/skill-conflict.test.ts +35 -0
  216. package/src/lib/chat/__tests__/stream-telemetry.test.ts +151 -0
  217. package/src/lib/chat/__tests__/types.test.ts +28 -0
  218. package/src/lib/chat/active-skills.ts +31 -0
  219. package/src/lib/chat/active-streams.ts +27 -0
  220. package/src/lib/chat/clean-filter-input.ts +30 -0
  221. package/src/lib/chat/codex-engine.ts +46 -24
  222. package/src/lib/chat/command-tabs.ts +61 -0
  223. package/src/lib/chat/context-builder.ts +146 -4
  224. package/src/lib/chat/dismissals.ts +73 -0
  225. package/src/lib/chat/engine.ts +159 -18
  226. package/src/lib/chat/files/__tests__/search.test.ts +135 -0
  227. package/src/lib/chat/files/expand-mention.ts +76 -0
  228. package/src/lib/chat/files/search.ts +99 -0
  229. package/src/lib/chat/reconcile.ts +117 -0
  230. package/src/lib/chat/skill-composition.ts +210 -0
  231. package/src/lib/chat/skill-conflict.ts +105 -0
  232. package/src/lib/chat/stagent-tools.ts +7 -19
  233. package/src/lib/chat/stream-telemetry.ts +137 -0
  234. package/src/lib/chat/suggested-prompts.ts +28 -1
  235. package/src/lib/chat/system-prompt.ts +48 -1
  236. package/src/lib/chat/tool-catalog.ts +35 -4
  237. package/src/lib/chat/tools/__tests__/enrich-table-tool.test.ts +127 -0
  238. package/src/lib/chat/tools/__tests__/profile-tools.test.ts +51 -0
  239. package/src/lib/chat/tools/__tests__/schedule-tools.test.ts +261 -0
  240. package/src/lib/chat/tools/__tests__/settings-tools.test.ts +294 -0
  241. package/src/lib/chat/tools/__tests__/skill-tools.test.ts +474 -0
  242. package/src/lib/chat/tools/__tests__/task-tools.test.ts +399 -0
  243. package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +351 -0
  244. package/src/lib/chat/tools/blueprint-tools.ts +190 -0
  245. package/src/lib/chat/tools/document-tools.ts +29 -13
  246. package/src/lib/chat/tools/helpers.ts +41 -0
  247. package/src/lib/chat/tools/notification-tools.ts +9 -5
  248. package/src/lib/chat/tools/profile-tools.ts +120 -23
  249. package/src/lib/chat/tools/project-tools.ts +33 -0
  250. package/src/lib/chat/tools/schedule-tools.ts +44 -11
  251. package/src/lib/chat/tools/skill-tools.ts +183 -0
  252. package/src/lib/chat/tools/table-tools.ts +71 -0
  253. package/src/lib/chat/tools/task-tools.ts +89 -21
  254. package/src/lib/chat/tools/workflow-tools.ts +275 -32
  255. package/src/lib/chat/types.ts +15 -0
  256. package/src/lib/constants/settings.ts +10 -18
  257. package/src/lib/data/__tests__/clear.test.ts +56 -2
  258. package/src/lib/data/clear.ts +17 -16
  259. package/src/lib/data/delete-project.ts +171 -0
  260. package/src/lib/db/__tests__/bootstrap.test.ts +1 -1
  261. package/src/lib/db/bootstrap.ts +62 -16
  262. package/src/lib/db/index.ts +5 -0
  263. package/src/lib/db/migrations/0009_add_app_instances.sql +25 -0
  264. package/src/lib/db/migrations/0024_add_workflow_resume_at.sql +10 -0
  265. package/src/lib/db/migrations/0025_drop_app_instances.sql +3 -0
  266. package/src/lib/db/migrations/0026_drop_license.sql +3 -0
  267. package/src/lib/db/migrations/meta/_journal.json +21 -0
  268. package/src/lib/db/schema.ts +94 -23
  269. package/src/lib/environment/__tests__/auto-promote.test.ts +132 -0
  270. package/src/lib/environment/__tests__/list-skills-enriched.test.ts +55 -0
  271. package/src/lib/environment/__tests__/skill-enrichment.test.ts +129 -0
  272. package/src/lib/environment/__tests__/skill-recommendations.test.ts +87 -0
  273. package/src/lib/environment/data.ts +9 -0
  274. package/src/lib/environment/list-skills.ts +176 -0
  275. package/src/lib/environment/parsers/__tests__/skill.test.ts +54 -0
  276. package/src/lib/environment/parsers/skill.ts +26 -5
  277. package/src/lib/environment/profile-generator.ts +54 -0
  278. package/src/lib/environment/skill-enrichment.ts +106 -0
  279. package/src/lib/environment/skill-recommendations.ts +66 -0
  280. package/src/lib/environment/workspace-context.ts +13 -1
  281. package/src/lib/filters/__tests__/parse.quoted.test.ts +40 -0
  282. package/src/lib/filters/__tests__/parse.test.ts +135 -0
  283. package/src/lib/filters/parse.ts +86 -0
  284. package/src/lib/import/dedup.ts +4 -54
  285. package/src/lib/instance/__tests__/bootstrap.test.ts +362 -0
  286. package/src/lib/instance/__tests__/detect.test.ts +115 -0
  287. package/src/lib/instance/__tests__/fingerprint.test.ts +48 -0
  288. package/src/lib/instance/__tests__/git-ops.test.ts +95 -0
  289. package/src/lib/instance/__tests__/settings.test.ts +83 -0
  290. package/src/lib/instance/__tests__/upgrade-poller.test.ts +181 -0
  291. package/src/lib/instance/bootstrap.ts +270 -0
  292. package/src/lib/instance/detect.ts +49 -0
  293. package/src/lib/instance/fingerprint.ts +76 -0
  294. package/src/lib/instance/git-ops.ts +95 -0
  295. package/src/lib/instance/settings.ts +61 -0
  296. package/src/lib/instance/types.ts +77 -0
  297. package/src/lib/instance/upgrade-poller.ts +205 -0
  298. package/src/lib/notifications/__tests__/visibility.test.ts +51 -0
  299. package/src/lib/notifications/visibility.ts +33 -0
  300. package/src/lib/schedules/__tests__/collision-check.test.ts +93 -0
  301. package/src/lib/schedules/__tests__/config.test.ts +62 -0
  302. package/src/lib/schedules/__tests__/firing-metrics.test.ts +99 -0
  303. package/src/lib/schedules/__tests__/integration.test.ts +82 -0
  304. package/src/lib/schedules/__tests__/slot-claim.test.ts +242 -0
  305. package/src/lib/schedules/__tests__/tick-scheduler.test.ts +102 -0
  306. package/src/lib/schedules/__tests__/turn-budget.test.ts +228 -0
  307. package/src/lib/schedules/collision-check.ts +105 -0
  308. package/src/lib/schedules/config.ts +53 -0
  309. package/src/lib/schedules/scheduler.ts +236 -17
  310. package/src/lib/schedules/slot-claim.ts +105 -0
  311. package/src/lib/settings/__tests__/openai-auth.test.ts +101 -0
  312. package/src/lib/settings/__tests__/openai-login-manager.test.ts +64 -0
  313. package/src/lib/settings/__tests__/runtime-setup.test.ts +33 -0
  314. package/src/lib/settings/openai-auth.ts +105 -10
  315. package/src/lib/settings/openai-login-manager.ts +260 -0
  316. package/src/lib/settings/runtime-setup.ts +14 -4
  317. package/src/lib/tables/__tests__/enrichment-planner.test.ts +124 -0
  318. package/src/lib/tables/__tests__/enrichment.test.ts +147 -0
  319. package/src/lib/tables/enrichment-planner.ts +454 -0
  320. package/src/lib/tables/enrichment.ts +328 -0
  321. package/src/lib/tables/query-builder.ts +5 -2
  322. package/src/lib/tables/trigger-evaluator.ts +3 -2
  323. package/src/lib/theme.ts +71 -0
  324. package/src/lib/usage/ledger.ts +2 -18
  325. package/src/lib/util/__tests__/similarity.test.ts +106 -0
  326. package/src/lib/util/similarity.ts +77 -0
  327. package/src/lib/utils/format-timestamp.ts +24 -0
  328. package/src/lib/utils/stagent-paths.ts +12 -0
  329. package/src/lib/validators/__tests__/blueprint.test.ts +172 -0
  330. package/src/lib/validators/__tests__/settings.test.ts +10 -0
  331. package/src/lib/validators/blueprint.ts +70 -9
  332. package/src/lib/validators/profile.ts +2 -2
  333. package/src/lib/validators/settings.ts +3 -1
  334. package/src/lib/workflows/__tests__/delay.test.ts +196 -0
  335. package/src/lib/workflows/__tests__/engine.test.ts +8 -0
  336. package/src/lib/workflows/__tests__/loop-executor.test.ts +54 -0
  337. package/src/lib/workflows/__tests__/post-action.test.ts +108 -0
  338. package/src/lib/workflows/blueprints/__tests__/render-prompt.test.ts +124 -0
  339. package/src/lib/workflows/blueprints/instantiator.ts +22 -1
  340. package/src/lib/workflows/blueprints/render-prompt.ts +71 -0
  341. package/src/lib/workflows/blueprints/types.ts +16 -2
  342. package/src/lib/workflows/delay.ts +106 -0
  343. package/src/lib/workflows/engine.ts +212 -7
  344. package/src/lib/workflows/loop-executor.ts +349 -24
  345. package/src/lib/workflows/post-action.ts +91 -0
  346. package/src/lib/workflows/types.ts +166 -1
  347. package/src/test/setup.ts +10 -0
  348. package/src/app/api/license/checkout/route.ts +0 -28
  349. package/src/app/api/license/portal/route.ts +0 -26
  350. package/src/app/api/license/route.ts +0 -89
  351. package/src/app/api/license/usage/route.ts +0 -63
  352. package/src/app/api/marketplace/browse/route.ts +0 -15
  353. package/src/app/api/marketplace/import/route.ts +0 -28
  354. package/src/app/api/marketplace/publish/route.ts +0 -40
  355. package/src/app/api/onboarding/email/route.ts +0 -53
  356. package/src/app/api/settings/telemetry/route.ts +0 -14
  357. package/src/app/api/sync/export/route.ts +0 -54
  358. package/src/app/api/sync/restore/route.ts +0 -37
  359. package/src/app/api/sync/sessions/route.ts +0 -24
  360. package/src/app/auth/callback/route.ts +0 -73
  361. package/src/app/marketplace/page.tsx +0 -19
  362. package/src/components/analytics/analytics-gate-card.tsx +0 -101
  363. package/src/components/marketplace/blueprint-card.tsx +0 -61
  364. package/src/components/marketplace/marketplace-browser.tsx +0 -131
  365. package/src/components/onboarding/email-capture-card.tsx +0 -104
  366. package/src/components/settings/activation-form.tsx +0 -95
  367. package/src/components/settings/cloud-account-section.tsx +0 -147
  368. package/src/components/settings/cloud-sync-section.tsx +0 -155
  369. package/src/components/settings/subscription-section.tsx +0 -410
  370. package/src/components/settings/telemetry-section.tsx +0 -80
  371. package/src/components/shared/premium-gate-overlay.tsx +0 -50
  372. package/src/components/shared/schedule-gate-dialog.tsx +0 -64
  373. package/src/components/shared/upgrade-banner.tsx +0 -112
  374. package/src/hooks/use-supabase-auth.ts +0 -79
  375. package/src/lib/billing/email.ts +0 -54
  376. package/src/lib/billing/products.ts +0 -80
  377. package/src/lib/billing/stripe.ts +0 -101
  378. package/src/lib/cloud/supabase-browser.ts +0 -32
  379. package/src/lib/cloud/supabase-client.ts +0 -56
  380. package/src/lib/license/__tests__/features.test.ts +0 -56
  381. package/src/lib/license/__tests__/key-format.test.ts +0 -88
  382. package/src/lib/license/__tests__/manager.test.ts +0 -64
  383. package/src/lib/license/__tests__/tier-limits.test.ts +0 -79
  384. package/src/lib/license/cloud-validation.ts +0 -60
  385. package/src/lib/license/features.ts +0 -44
  386. package/src/lib/license/key-format.ts +0 -101
  387. package/src/lib/license/limit-check.ts +0 -111
  388. package/src/lib/license/limit-queries.ts +0 -51
  389. package/src/lib/license/manager.ts +0 -345
  390. package/src/lib/license/notifications.ts +0 -59
  391. package/src/lib/license/tier-limits.ts +0 -71
  392. package/src/lib/marketplace/marketplace-client.ts +0 -107
  393. package/src/lib/sync/cloud-sync.ts +0 -235
  394. package/src/lib/telemetry/conversion-events.ts +0 -71
  395. package/src/lib/telemetry/queue.ts +0 -122
  396. package/src/lib/validators/license.ts +0 -33
@@ -0,0 +1,151 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import {
3
+ recordTermination,
4
+ readTerminations,
5
+ countTerminations,
6
+ __resetForTesting,
7
+ } from "../stream-telemetry";
8
+
9
+ describe("stream-telemetry ring buffer", () => {
10
+ beforeEach(() => {
11
+ __resetForTesting();
12
+ });
13
+
14
+ it("returns [] before any events are recorded", () => {
15
+ expect(readTerminations()).toEqual([]);
16
+ });
17
+
18
+ it("records events in chronological order", () => {
19
+ recordTermination({
20
+ reason: "stream.completed",
21
+ conversationId: "c1",
22
+ messageId: "m1",
23
+ durationMs: 100,
24
+ });
25
+ recordTermination({
26
+ reason: "stream.aborted.client",
27
+ conversationId: "c2",
28
+ messageId: "m2",
29
+ durationMs: 50,
30
+ });
31
+
32
+ const events = readTerminations();
33
+ expect(events).toHaveLength(2);
34
+ expect(events[0].reason).toBe("stream.completed");
35
+ expect(events[1].reason).toBe("stream.aborted.client");
36
+ expect(events[0].timestamp).toBeLessThanOrEqual(events[1].timestamp);
37
+ });
38
+
39
+ it("stamps each event with a timestamp", () => {
40
+ const before = Date.now();
41
+ recordTermination({
42
+ reason: "stream.completed",
43
+ conversationId: "c1",
44
+ messageId: "m1",
45
+ durationMs: 0,
46
+ });
47
+ const after = Date.now();
48
+ const events = readTerminations();
49
+ expect(events[0].timestamp).toBeGreaterThanOrEqual(before);
50
+ expect(events[0].timestamp).toBeLessThanOrEqual(after);
51
+ });
52
+
53
+ it("wraps around after 500 events, preserving newest-500 in order", () => {
54
+ // Write 520 events — first 20 should be evicted.
55
+ for (let i = 0; i < 520; i++) {
56
+ recordTermination({
57
+ reason: "stream.completed",
58
+ conversationId: `c${i}`,
59
+ messageId: `m${i}`,
60
+ durationMs: i,
61
+ });
62
+ }
63
+
64
+ const events = readTerminations();
65
+ expect(events).toHaveLength(500);
66
+ // Oldest surviving event should be #20; newest should be #519.
67
+ expect(events[0].conversationId).toBe("c20");
68
+ expect(events[0].durationMs).toBe(20);
69
+ expect(events[499].conversationId).toBe("c519");
70
+ expect(events[499].durationMs).toBe(519);
71
+ });
72
+
73
+ it("countTerminations groups by reason code across the full buffer", () => {
74
+ recordTermination({ reason: "stream.completed", conversationId: "c", messageId: "m", durationMs: 1 });
75
+ recordTermination({ reason: "stream.completed", conversationId: "c", messageId: "m", durationMs: 1 });
76
+ recordTermination({ reason: "stream.aborted.client", conversationId: "c", messageId: "m", durationMs: 1 });
77
+ recordTermination({ reason: "stream.finalized.error", conversationId: "c", messageId: "m", durationMs: 1, error: "boom" });
78
+ recordTermination({ reason: "stream.abandoned", conversationId: "c", messageId: "m", durationMs: 42 });
79
+
80
+ const counts = countTerminations();
81
+ expect(counts["stream.completed"]).toBe(2);
82
+ expect(counts["stream.aborted.client"]).toBe(1);
83
+ expect(counts["stream.finalized.error"]).toBe(1);
84
+ expect(counts["stream.abandoned"]).toBe(1);
85
+ expect(counts["stream.aborted.signal"]).toBe(0);
86
+ expect(counts["stream.reconciled.stale"]).toBe(0);
87
+ });
88
+
89
+ it("stream.abandoned is a valid reason code for iterator abandonment", () => {
90
+ // finalizeStreamingMessage records this when the engine's happy and
91
+ // catch paths both missed the termination — the canonical "gap"
92
+ // indicator. Make sure it round-trips through the buffer.
93
+ recordTermination({
94
+ reason: "stream.abandoned",
95
+ conversationId: "c1",
96
+ messageId: "m1",
97
+ durationMs: 100,
98
+ error: "no content streamed before abandonment",
99
+ });
100
+ const events = readTerminations();
101
+ expect(events[0].reason).toBe("stream.abandoned");
102
+ expect(events[0].error).toBe("no content streamed before abandonment");
103
+ });
104
+
105
+ it("countTerminations honors the windowMs filter", async () => {
106
+ recordTermination({ reason: "stream.completed", conversationId: "c", messageId: "m", durationMs: 1 });
107
+ // Wait a few ms so the second event has a strictly later timestamp.
108
+ await new Promise((r) => setTimeout(r, 10));
109
+ const midpoint = Date.now();
110
+ await new Promise((r) => setTimeout(r, 10));
111
+ recordTermination({ reason: "stream.completed", conversationId: "c", messageId: "m", durationMs: 1 });
112
+
113
+ // Use a window that only includes the second event.
114
+ const windowMs = Date.now() - midpoint + 5;
115
+ const counts = countTerminations(windowMs);
116
+ expect(counts["stream.completed"]).toBe(1);
117
+ });
118
+
119
+ it("readTerminations returns a copy, not a live reference", () => {
120
+ recordTermination({ reason: "stream.completed", conversationId: "c", messageId: "m", durationMs: 1 });
121
+ const first = readTerminations();
122
+ recordTermination({ reason: "stream.completed", conversationId: "c2", messageId: "m2", durationMs: 1 });
123
+ // first snapshot should still have only the initial event.
124
+ expect(first).toHaveLength(1);
125
+ expect(readTerminations()).toHaveLength(2);
126
+ });
127
+
128
+ it("records optional error strings on error events", () => {
129
+ recordTermination({
130
+ reason: "stream.finalized.error",
131
+ conversationId: "c",
132
+ messageId: "m",
133
+ durationMs: 42,
134
+ error: "boom",
135
+ });
136
+ expect(readTerminations()[0].error).toBe("boom");
137
+ });
138
+
139
+ it("allows null conversationId / messageId / durationMs for edge cases", () => {
140
+ recordTermination({
141
+ reason: "stream.reconciled.stale",
142
+ conversationId: null,
143
+ messageId: null,
144
+ durationMs: null,
145
+ });
146
+ const events = readTerminations();
147
+ expect(events[0].conversationId).toBeNull();
148
+ expect(events[0].messageId).toBeNull();
149
+ expect(events[0].durationMs).toBeNull();
150
+ });
151
+ });
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { getFeaturesForModel, getRuntimeForModel } from "@/lib/chat/types";
3
+
4
+ describe("getFeaturesForModel", () => {
5
+ it("returns Claude features for a Claude model id", () => {
6
+ const features = getFeaturesForModel("sonnet");
7
+ expect(features.hasNativeSkills).toBe(true);
8
+ expect(features.autoLoadsInstructions).toBe("CLAUDE.md");
9
+ });
10
+
11
+ it("returns Ollama features for an ollama-prefixed model id", () => {
12
+ const features = getFeaturesForModel("ollama:llama3");
13
+ expect(features.stagentInjectsSkills).toBe(true);
14
+ expect(features.hasNativeSkills).toBe(false);
15
+ });
16
+
17
+ it("returns Codex features for a GPT model id", () => {
18
+ const features = getFeaturesForModel("gpt-5.4");
19
+ expect(features.autoLoadsInstructions).toBe("AGENTS.md");
20
+ });
21
+
22
+ it("falls back to claude-code features for an unknown model id", () => {
23
+ // getRuntimeForModel's fallback chain lands on claude-code for unknown ids.
24
+ const features = getFeaturesForModel("totally-made-up-model");
25
+ expect(features.hasNativeSkills).toBe(true);
26
+ expect(getRuntimeForModel("totally-made-up-model")).toBe("claude-code");
27
+ });
28
+ });
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Pure helper for combining the legacy `conversations.active_skill_id`
3
+ * column with the new `conversations.active_skill_ids` JSON array
4
+ * (`features/chat-skill-composition.md`).
5
+ *
6
+ * Lives in its own module (no DB imports) so client components can use
7
+ * it without pulling server-only code into the bundle. The original
8
+ * lived alongside the chat-tool definition in `tools/skill-tools.ts`,
9
+ * which can only run server-side.
10
+ */
11
+
12
+ export function mergeActiveSkillIds(
13
+ legacyId: string | null | undefined,
14
+ composed: string[] | null | undefined
15
+ ): string[] {
16
+ const out: string[] = [];
17
+ const seen = new Set<string>();
18
+ if (legacyId) {
19
+ out.push(legacyId);
20
+ seen.add(legacyId);
21
+ }
22
+ if (composed) {
23
+ for (const id of composed) {
24
+ if (id && !seen.has(id)) {
25
+ out.push(id);
26
+ seen.add(id);
27
+ }
28
+ }
29
+ }
30
+ return out;
31
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * In-memory tracker for chat conversations that currently have an SSE stream
3
+ * in flight. Used by the scheduler tick loop to apply a soft pressure signal
4
+ * — when chat is active, new schedule firings are deferred by N seconds to
5
+ * keep the Node event loop responsive for the user's conversation.
6
+ *
7
+ * Module-level state; single-process (same Node instance as the scheduler).
8
+ * Must NOT be persisted — crash recovery relies on the set starting empty.
9
+ */
10
+
11
+ const activeStreams = new Set<string>();
12
+
13
+ export function registerChatStream(conversationId: string): void {
14
+ activeStreams.add(conversationId);
15
+ }
16
+
17
+ export function unregisterChatStream(conversationId: string): void {
18
+ activeStreams.delete(conversationId);
19
+ }
20
+
21
+ export function getActiveChatStreamCount(): number {
22
+ return activeStreams.size;
23
+ }
24
+
25
+ export function isAnyChatStreaming(): boolean {
26
+ return activeStreams.size > 0;
27
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Sanitize the filterInput we persist for a saved search.
3
+ *
4
+ * The chat popover input may include the mention trigger prefix
5
+ * (e.g. `@task: ` or `task: ` depending on what the trigger regex
6
+ * stripped). When the user "Saves this view", we want the persisted
7
+ * filterInput to contain ONLY the meaningful filter expression —
8
+ * `#key:value` clauses plus any free-text search the user typed —
9
+ * not the trigger residue.
10
+ *
11
+ * Pure function. Tested in isolation; called from
12
+ * `chat-command-popover.tsx` at the SaveViewFooter call site.
13
+ *
14
+ * See `features/saved-search-polish-v1.md` for the bug history.
15
+ */
16
+
17
+ import type { FilterClause } from "@/lib/filters/parse";
18
+
19
+ const TRIGGER_RESIDUE = /^@?[a-z]+:\s*/i;
20
+
21
+ export function cleanFilterInput(
22
+ clauses: FilterClause[],
23
+ rawQuery: string
24
+ ): string {
25
+ const cleanRawQuery = rawQuery.replace(TRIGGER_RESIDUE, "").trim();
26
+ return [
27
+ ...clauses.map((c) => `#${c.key}:${c.value}`),
28
+ ...(cleanRawQuery ? [cleanRawQuery] : []),
29
+ ].join(" ");
30
+ }
@@ -2,7 +2,10 @@ import { db } from "@/lib/db";
2
2
  import { projects } from "@/lib/db/schema";
3
3
  import { eq } from "drizzle-orm";
4
4
  import { CodexAppServerClient } from "@/lib/agents/runtime/codex-app-server-client";
5
- import { getOpenAIApiKey } from "@/lib/settings/openai-auth";
5
+ import {
6
+ ensureOpenAICodexClientAuthenticated,
7
+ resolveOpenAICodexAuthContext,
8
+ } from "@/lib/agents/runtime/openai-codex-auth";
6
9
  import {
7
10
  extractUsageSnapshot,
8
11
  mergeUsageSnapshot,
@@ -31,6 +34,7 @@ import {
31
34
  cleanupConversation,
32
35
  } from "./permission-bridge";
33
36
  import { getWorkspaceContext } from "@/lib/environment/workspace-context";
37
+ import type { ResolvedExecutionTarget } from "@/lib/agents/runtime/execution-target";
34
38
 
35
39
  // ── Helpers ──────────────────────────────────────────────────────────────
36
40
 
@@ -54,7 +58,8 @@ function asString(v: unknown): string | null {
54
58
  export async function* sendCodexMessage(
55
59
  conversationId: string,
56
60
  userContent: string,
57
- signal?: AbortSignal
61
+ signal?: AbortSignal,
62
+ targetOverride?: ResolvedExecutionTarget
58
63
  ): AsyncGenerator<ChatStreamEvent> {
59
64
  const conversation = await getConversation(conversationId);
60
65
  if (!conversation) {
@@ -62,7 +67,7 @@ export async function* sendCodexMessage(
62
67
  return;
63
68
  }
64
69
 
65
- const runtimeId = conversation.runtimeId;
70
+ const runtimeId = targetOverride?.effectiveRuntimeId ?? conversation.runtimeId;
66
71
  const providerId = getProviderForRuntime(runtimeId);
67
72
 
68
73
  // Enforce budget
@@ -128,11 +133,17 @@ export async function* sendCodexMessage(
128
133
  });
129
134
 
130
135
  // Get OpenAI API key
131
- const { apiKey } = await getOpenAIApiKey();
132
- if (!apiKey) {
133
- await updateMessageContent(assistantMsg.id, "OpenAI API key is not configured. Add it in Settings → Auth.");
136
+ let auth;
137
+ try {
138
+ auth = await resolveOpenAICodexAuthContext();
139
+ } catch (error) {
140
+ const message =
141
+ error instanceof Error
142
+ ? error.message
143
+ : "OpenAI Codex authentication is not configured.";
144
+ await updateMessageContent(assistantMsg.id, message);
134
145
  await updateMessageStatus(assistantMsg.id, "error");
135
- yield { type: "error", message: "OpenAI API key is not configured. Add it in Settings → Auth." };
146
+ yield { type: "error", message };
136
147
  return;
137
148
  }
138
149
 
@@ -164,20 +175,10 @@ export async function* sendCodexMessage(
164
175
  }
165
176
 
166
177
  try {
167
- client = await CodexAppServerClient.connect({
168
- cwd: workspace.cwd,
169
- env: { OPENAI_API_KEY: apiKey },
170
- });
178
+ client = await auth.connect(workspace.cwd);
171
179
 
172
180
  // Initialize and authenticate
173
- await client.request("initialize", {
174
- clientInfo: { name: "Stagent", version: "0.1.1" },
175
- capabilities: null,
176
- });
177
- await client.request("account/login/start", {
178
- type: "apiKey",
179
- apiKey,
180
- });
181
+ await ensureOpenAICodexClientAuthenticated(client, auth);
181
182
 
182
183
  // Validate model availability against what the user's account supports
183
184
  let validatedModel: string | undefined;
@@ -188,8 +189,10 @@ export async function* sendCodexMessage(
188
189
  const availableIds = new Set(
189
190
  (modelResponse.models ?? []).map((m: { id: string }) => m.id)
190
191
  );
191
- if (conversation.modelId && availableIds.has(conversation.modelId)) {
192
- validatedModel = conversation.modelId;
192
+ const requestedModelId =
193
+ targetOverride?.effectiveModelId ?? conversation.modelId;
194
+ if (requestedModelId && availableIds.has(requestedModelId)) {
195
+ validatedModel = requestedModelId;
193
196
  }
194
197
  // If not available, validatedModel stays undefined → Codex uses its default
195
198
  } catch {
@@ -377,7 +380,18 @@ export async function* sendCodexMessage(
377
380
 
378
381
  // Save usage metadata
379
382
  const metadata = JSON.stringify({
380
- modelId: usage.modelId ?? conversation.modelId,
383
+ modelId:
384
+ usage.modelId ??
385
+ targetOverride?.effectiveModelId ??
386
+ conversation.modelId,
387
+ runtimeId,
388
+ requestedRuntimeId:
389
+ targetOverride?.requestedRuntimeId ?? conversation.runtimeId,
390
+ requestedModelId:
391
+ targetOverride?.requestedModelId ?? conversation.modelId,
392
+ ...(targetOverride?.fallbackReason
393
+ ? { fallbackReason: targetOverride.fallbackReason }
394
+ : {}),
381
395
  inputTokens: usage.inputTokens,
382
396
  outputTokens: usage.outputTokens,
383
397
  ...(quickAccess.length > 0 ? { quickAccess } : {}),
@@ -394,7 +408,11 @@ export async function* sendCodexMessage(
394
408
  activityType: "chat_turn",
395
409
  runtimeId,
396
410
  providerId,
397
- modelId: usage.modelId ?? conversation.modelId ?? null,
411
+ modelId:
412
+ usage.modelId ??
413
+ targetOverride?.effectiveModelId ??
414
+ conversation.modelId ??
415
+ null,
398
416
  inputTokens: usage.inputTokens ?? null,
399
417
  outputTokens: usage.outputTokens ?? null,
400
418
  totalTokens: usage.totalTokens ?? null,
@@ -414,7 +432,11 @@ export async function* sendCodexMessage(
414
432
  activityType: "chat_turn",
415
433
  runtimeId,
416
434
  providerId,
417
- modelId: usage.modelId ?? conversation.modelId ?? null,
435
+ modelId:
436
+ usage.modelId ??
437
+ targetOverride?.effectiveModelId ??
438
+ conversation.modelId ??
439
+ null,
418
440
  inputTokens: usage.inputTokens ?? null,
419
441
  outputTokens: usage.outputTokens ?? null,
420
442
  totalTokens: usage.totalTokens ?? null,
@@ -0,0 +1,61 @@
1
+ import type { ToolCatalogEntry, ToolGroup } from "./tool-catalog";
2
+
3
+ export const COMMAND_TAB_IDS = ["actions", "skills", "tools", "entities"] as const;
4
+ export type CommandTabId = (typeof COMMAND_TAB_IDS)[number];
5
+
6
+ export interface CommandTab {
7
+ id: CommandTabId;
8
+ label: string;
9
+ shortcut: string; // ⌘1..⌘4
10
+ }
11
+
12
+ export const COMMAND_TABS: CommandTab[] = [
13
+ { id: "actions", label: "Actions", shortcut: "⌘1" },
14
+ { id: "skills", label: "Skills", shortcut: "⌘2" },
15
+ { id: "tools", label: "Tools", shortcut: "⌘3" },
16
+ { id: "entities", label: "Entities", shortcut: "⌘4" },
17
+ ];
18
+
19
+ export const DEFAULT_COMMAND_TAB: CommandTabId = "actions";
20
+
21
+ export const GROUP_TO_TAB = {
22
+ // Stagent actions / session primitives
23
+ Session: "actions",
24
+ Tasks: "actions",
25
+ Projects: "actions",
26
+ Workflows: "actions",
27
+ Schedules: "actions",
28
+ Documents: "actions",
29
+ Tables: "actions",
30
+ Notifications: "actions",
31
+ Profiles: "actions",
32
+ Usage: "actions",
33
+ Settings: "actions",
34
+ Chat: "actions",
35
+ // Skills
36
+ Skills: "skills",
37
+ // Tools (filesystem / system / utility)
38
+ Browser: "tools",
39
+ Utility: "tools",
40
+ } satisfies Record<ToolGroup, CommandTabId>;
41
+
42
+ export function isCommandTabId(value: string): value is CommandTabId {
43
+ return (COMMAND_TAB_IDS as readonly string[]).includes(value);
44
+ }
45
+
46
+ export interface PartitionedCatalog {
47
+ actions: ToolCatalogEntry[];
48
+ skills: ToolCatalogEntry[];
49
+ tools: ToolCatalogEntry[];
50
+ entities: ToolCatalogEntry[];
51
+ }
52
+
53
+ export function partitionCatalogByTab(
54
+ catalog: ToolCatalogEntry[]
55
+ ): PartitionedCatalog {
56
+ const out: PartitionedCatalog = { actions: [], skills: [], tools: [], entities: [] };
57
+ for (const entry of catalog) {
58
+ out[GROUP_TO_TAB[entry.group]].push(entry);
59
+ }
60
+ return out;
61
+ }
@@ -5,6 +5,8 @@ import { getMessages } from "@/lib/data/chat";
5
5
  import { getProfile } from "@/lib/agents/profiles/registry";
6
6
  import { STAGENT_SYSTEM_PROMPT } from "./system-prompt";
7
7
  import type { WorkspaceContext } from "@/lib/environment/workspace-context";
8
+ import { expandFileMention } from "./files/expand-mention";
9
+ import { conversations } from "@/lib/db/schema";
8
10
 
9
11
  // ── Token budget constants ─────────────────────────────────────────────
10
12
 
@@ -50,6 +52,121 @@ function buildTier0(
50
52
  return parts.join("\n");
51
53
  }
52
54
 
55
+ // ── Active skill injection (Ollama-first, runtime-agnostic) ────────────
56
+
57
+ /**
58
+ * Token budget for a conversation-bound skill's SKILL.md content.
59
+ *
60
+ * Per spec §7.1: 1000-4000 tokens typical, with 300 tokens of index/
61
+ * metadata on top. We cap at ~4000 tokens (≈16K chars) so a large skill
62
+ * can't blow out a small-context local model. Single-active-skill is
63
+ * enforced at the MCP-tool layer.
64
+ */
65
+ const ACTIVE_SKILL_BUDGET = 4_000;
66
+
67
+ interface ActiveSkillSection {
68
+ name: string;
69
+ text: string;
70
+ }
71
+
72
+ function renderActiveSkillSections(
73
+ kept: ActiveSkillSection[],
74
+ omitted: ActiveSkillSection[]
75
+ ): string {
76
+ if (kept.length === 0) return "";
77
+
78
+ const parts: string[] = [];
79
+ if (omitted.length > 0) {
80
+ const label = omitted.length === 1 ? "skill" : "skills";
81
+ parts.push(
82
+ `## Active Skill Note\nOmitted ${omitted.length} older active ${label} to fit the prompt budget: ${omitted
83
+ .map((section) => section.name)
84
+ .join(", ")}.`
85
+ );
86
+ }
87
+ parts.push(...kept.map((section) => section.text));
88
+ return parts.join("\n\n---\n\n");
89
+ }
90
+
91
+ /**
92
+ * Build the "Active Skill" section of the system prompt, if one is bound
93
+ * to the conversation via `conversations.active_skill_id`. Returns "" for
94
+ * conversations without an active skill.
95
+ *
96
+ * Primary use case: Ollama has no SDK-native skill support, so this is
97
+ * how SKILL.md reaches a local model. Claude and Codex runtimes can
98
+ * also bind a skill via this path alongside their native Skill tools.
99
+ *
100
+ * See `features/chat-ollama-native-skills.md`.
101
+ */
102
+ async function buildActiveSkill(conversationId: string): Promise<string> {
103
+ const row = await db
104
+ .select({
105
+ activeSkillId: conversations.activeSkillId,
106
+ activeSkillIds: conversations.activeSkillIds,
107
+ runtimeId: conversations.runtimeId,
108
+ })
109
+ .from(conversations)
110
+ .where(eq(conversations.id, conversationId))
111
+ .get();
112
+
113
+ // Merge legacy single-active + new composed array. Dynamic import to
114
+ // avoid loading the chat tools module on the hot path / risk import
115
+ // cycles per the runtime-catalog smoke-test budget rule in MEMORY.md.
116
+ const { mergeActiveSkillIds } = await import("@/lib/chat/active-skills");
117
+ const merged = mergeActiveSkillIds(row?.activeSkillId, row?.activeSkillIds);
118
+ if (merged.length === 0) return "";
119
+
120
+ // Composition (any entry in the new activeSkillIds column) is an
121
+ // explicit user opt-in to override the SDK-native default. Without
122
+ // this carve-out, composed skills would silently no-op on Claude/
123
+ // Codex where stagentInjectsSkills=false. When only the legacy
124
+ // activeSkillId is set, fall back to the original capability gate
125
+ // (Ollama-only injection).
126
+ const isComposed = (row?.activeSkillIds?.length ?? 0) > 0;
127
+
128
+ if (!isComposed && row?.runtimeId) {
129
+ try {
130
+ const { getRuntimeFeatures } = await import("@/lib/agents/runtime/catalog");
131
+ const features = getRuntimeFeatures(
132
+ row.runtimeId as Parameters<typeof getRuntimeFeatures>[0]
133
+ );
134
+ if (!features.stagentInjectsSkills) return "";
135
+ } catch {
136
+ // Unknown runtime — fall through and inject (safer default than
137
+ // silently dropping the skill on an unrecognized runtime id).
138
+ }
139
+ }
140
+
141
+ // Dynamic import keeps the scanner + fs dependency off the hot path for
142
+ // conversations that don't have an active skill (the common case).
143
+ const { getSkill } = await import("@/lib/environment/list-skills");
144
+ const sections: ActiveSkillSection[] = [];
145
+ for (const id of merged) {
146
+ const skill = getSkill(id);
147
+ if (!skill) continue;
148
+ sections.push({
149
+ name: skill.name,
150
+ text: `## Active Skill: ${skill.name}\n\n${skill.content}`,
151
+ });
152
+ }
153
+ if (sections.length === 0) return "";
154
+
155
+ const kept = [...sections];
156
+ const omitted: ActiveSkillSection[] = [];
157
+ while (
158
+ kept.length > 1 &&
159
+ estimateTokens(renderActiveSkillSections(kept, omitted)) > ACTIVE_SKILL_BUDGET
160
+ ) {
161
+ const oldest = kept.shift();
162
+ if (oldest) omitted.push(oldest);
163
+ }
164
+
165
+ const combined = renderActiveSkillSections(kept, omitted);
166
+ if (estimateTokens(combined) <= ACTIVE_SKILL_BUDGET) return combined;
167
+ return truncateToTokenBudget(combined, ACTIVE_SKILL_BUDGET);
168
+ }
169
+
53
170
  // ── Tier 1: Conversation history ───────────────────────────────────────
54
171
 
55
172
  interface HistoryMessage {
@@ -108,7 +225,7 @@ async function buildTier2(projectId?: string | null): Promise<string> {
108
225
  if (recentTasks.length > 0) {
109
226
  parts.push("\n### Recent Tasks");
110
227
  for (const t of recentTasks) {
111
- parts.push(`- [${t.status}] ${t.title} (id: ${t.id.slice(0, 8)})`);
228
+ parts.push(`- [${t.status}] ${t.title} (id: ${t.id})`);
112
229
  }
113
230
  }
114
231
 
@@ -123,7 +240,7 @@ async function buildTier2(projectId?: string | null): Promise<string> {
123
240
  if (activeWorkflows.length > 0) {
124
241
  parts.push("\n### Workflows");
125
242
  for (const w of activeWorkflows) {
126
- parts.push(`- [${w.status}] ${w.name} (id: ${w.id.slice(0, 8)})`);
243
+ parts.push(`- [${w.status}] ${w.name} (id: ${w.id})`);
127
244
  }
128
245
  }
129
246
 
@@ -137,7 +254,7 @@ async function buildTier2(projectId?: string | null): Promise<string> {
137
254
  if (docs.length > 0) {
138
255
  parts.push(`\n### Documents (${docs.length})`);
139
256
  for (const d of docs) {
140
- parts.push(`- ${d.filename} (id: ${d.id.slice(0, 8)})`);
257
+ parts.push(`- ${d.filename} (id: ${d.id})`);
141
258
  }
142
259
  }
143
260
 
@@ -278,6 +395,23 @@ async function buildTier3(mentions: MentionReference[]): Promise<string> {
278
395
  }
279
396
  break;
280
397
  }
398
+ case "file": {
399
+ // `entityId` is a relative path scoped to the active project's
400
+ // workingDirectory (preferred) or the stagent launch cwd (fallback).
401
+ // Security is enforced inside expandFileMention — the caller cannot
402
+ // influence cwd.
403
+ const { getLaunchCwd } = await import("@/lib/environment/workspace-context");
404
+ let cwd = getLaunchCwd();
405
+ // If the mention has a known project context in scope, prefer the
406
+ // project's workingDirectory. We don't have it at this scope today,
407
+ // so launch cwd is the safe default — matches the API route.
408
+ // (Future: plumb projectId into buildTier3 so file expansion honors
409
+ // per-project cwds exactly the same way as the search API.)
410
+ void cwd;
411
+ cwd = getLaunchCwd();
412
+ parts.push(...expandFileMention(mention.entityId, cwd));
413
+ break;
414
+ }
281
415
  }
282
416
  }
283
417
 
@@ -285,6 +419,7 @@ async function buildTier3(mentions: MentionReference[]): Promise<string> {
285
419
  return truncateToTokenBudget(text, TIER_3_BUDGET);
286
420
  }
287
421
 
422
+
288
423
  // ── Public API ─────────────────────────────────────────────────────────
289
424
 
290
425
  export interface ChatContext {
@@ -303,15 +438,22 @@ export async function buildChatContext(opts: {
303
438
  workspace?: WorkspaceContext | null;
304
439
  mentions?: MentionReference[];
305
440
  }): Promise<ChatContext> {
306
- const [history, tier2, tier3] = await Promise.all([
441
+ const [history, tier2, tier3, activeSkill] = await Promise.all([
307
442
  buildTier1(opts.conversationId),
308
443
  buildTier2(opts.projectId),
309
444
  buildTier3(opts.mentions ?? []),
445
+ buildActiveSkill(opts.conversationId),
310
446
  ]);
311
447
 
312
448
  const tier0 = buildTier0(opts.projectName, opts.workspace);
313
449
 
314
450
  const systemParts = [tier0];
451
+
452
+ // Active skill (from conversations.active_skill_id) sits right below
453
+ // Tier 0 so its instructions carry the most weight. Empty string when
454
+ // no skill is bound — common case.
455
+ if (activeSkill) systemParts.push(activeSkill);
456
+
315
457
  if (tier3) systemParts.push(tier3);
316
458
  if (tier2) systemParts.push(tier2);
317
459