From a94305af0b4e94f1a8058ccfe7634760802e8d5e Mon Sep 17 00:00:00 2001 From: Igor Silva Date: Thu, 18 Jun 2026 14:08:39 +0100 Subject: [PATCH] checkpoint pro safety snapshot --- .config/MasterPlan.md | 33 +- apps/meseeks/convex/_generated/api.d.ts | 34 +- apps/meseeks/convex/action.private.ts | 378 ------ apps/meseeks/convex/action.ts | 124 -- apps/meseeks/convex/action/details.private.ts | 87 +- apps/meseeks/convex/action/details.ts | 18 +- apps/meseeks/convex/actions.private.ts | 62 + apps/meseeks/convex/actions.ts | 124 ++ apps/meseeks/convex/babel.ts | 2 +- apps/meseeks/convex/betterAuth/component.ts | 2 + apps/meseeks/convex/compile.private.ts | 385 ++++++ apps/meseeks/convex/components.private.ts | 79 -- apps/meseeks/convex/components.ts | 64 - apps/meseeks/convex/drafts.private.ts | 65 - apps/meseeks/convex/drafts.ts | 41 - apps/meseeks/convex/execute.private.ts | 49 + apps/meseeks/convex/files.private.ts | 897 ++++++++++++++ apps/meseeks/convex/files.ts | 132 ++ .../convex/instincts/commitUpload.private.ts | 117 ++ .../convex/instincts/compile.private.ts | 35 + .../convex/instincts/create.private.ts | 76 ++ .../convex/instincts/createTrigger.private.ts | 98 ++ .../instincts/disableTrigger.private.ts | 34 + .../convex/instincts/execute.private.ts | 35 + .../meseeks/convex/instincts/index.private.ts | 64 + .../convex/instincts/interrupt.private.ts | 26 + apps/meseeks/convex/instincts/move.private.ts | 39 + .../convex/instincts/prepareUpload.private.ts | 102 ++ .../convex/instincts/request.private.ts | 41 + apps/meseeks/convex/instincts/say.private.ts | 34 + apps/meseeks/convex/instincts/seed.private.ts | 185 +++ apps/meseeks/convex/instincts/tag.private.ts | 38 + .../meseeks/convex/instincts/think.private.ts | 49 + .../meseeks/convex/instincts/untag.private.ts | 36 + .../meseeks/convex/instincts/write.private.ts | 102 ++ apps/meseeks/convex/magicRock.private.ts | 821 ++----------- apps/meseeks/convex/magicRock.tsx | 85 -- apps/meseeks/convex/migrations.ts | 32 +- apps/meseeks/convex/pages.private.ts | 108 ++ apps/meseeks/convex/pages.ts | 46 + apps/meseeks/convex/polarEvents.private.ts | 16 + apps/meseeks/convex/polarEvents.ts | 8 + apps/meseeks/convex/reactor.constants.ts | 5 - apps/meseeks/convex/reactor.private.ts | 427 +------ apps/meseeks/convex/reactor.ts | 265 +--- apps/meseeks/convex/reactor/apply.private.ts | 257 ++++ apps/meseeks/convex/reactor/claim.private.ts | 388 ++++++ .../meseeks/convex/reactor/perform.private.ts | 273 +++++ apps/meseeks/convex/reactor/settle.private.ts | 189 +++ apps/meseeks/convex/reactor/stage.private.ts | 42 + apps/meseeks/convex/request.private.ts | 95 ++ .../convex/schedule/lifecycle.private.ts | 151 --- apps/meseeks/convex/schedule/lifecycle.ts | 14 - apps/meseeks/convex/schedules.private.ts | 265 ---- apps/meseeks/convex/schedules.ts | 87 -- apps/meseeks/convex/schema.ts | 142 ++- apps/meseeks/convex/seed.ts | 167 --- apps/meseeks/convex/skills.private.ts | 489 ++++---- apps/meseeks/convex/skills.ts | 147 ++- apps/meseeks/convex/storage.private.ts | 207 ++++ apps/meseeks/convex/subscriptions.private.ts | 191 --- apps/meseeks/convex/subscriptions.ts | 90 -- apps/meseeks/convex/tasks.private.ts | 624 ---------- apps/meseeks/convex/tasks.ts | 226 ---- apps/meseeks/convex/topUps.private.ts | 68 +- apps/meseeks/convex/topUps.ts | 71 +- apps/meseeks/convex/transactions.private.ts | 57 +- apps/meseeks/convex/transactions.ts | 46 - apps/meseeks/convex/triggers.private.ts | 319 +++++ apps/meseeks/convex/triggers.ts | 24 + apps/meseeks/convex/users.private.ts | 195 +-- apps/meseeks/convex/users.ts | 26 +- .../convex/users/preferences.private.ts | 49 - apps/meseeks/convex/users/preferences.ts | 39 - apps/meseeks/convex/users/themes.ts | 72 -- apps/meseeks/lib/cron.ts | 50 - apps/meseeks/lib/instinct.ts | 61 + apps/meseeks/lib/polar.ts | 98 +- apps/meseeks/package.json | 18 +- apps/meseeks/schemas/actionDetailSchema.tsx | 279 +++-- apps/meseeks/schemas/actionSchema.tsx | 88 +- apps/meseeks/schemas/boxSchema.ts | 24 + apps/meseeks/schemas/componentSchema.tsx | 20 - apps/meseeks/schemas/draftSchema.ts | 15 - apps/meseeks/schemas/envSchema.ts | 121 +- apps/meseeks/schemas/fileRevisionSchema.ts | 24 + apps/meseeks/schemas/fileSchema.ts | 37 + apps/meseeks/schemas/intelligenceSchema.ts | 510 +------- apps/meseeks/schemas/pageSchema.ts | 22 + apps/meseeks/schemas/polarEventSchema.ts | 53 +- apps/meseeks/schemas/reactorSchema.ts | 204 ++++ apps/meseeks/schemas/scheduleSchema.ts | 61 - apps/meseeks/schemas/skillSchema.tsx | 404 +++--- apps/meseeks/schemas/subscriptionSchema.tsx | 23 - apps/meseeks/schemas/taskSchema.tsx | 54 - apps/meseeks/schemas/toolSchema.tsx | 2 +- apps/meseeks/schemas/topUpSchema.tsx | 36 +- apps/meseeks/schemas/transactionSchema.tsx | 65 +- apps/meseeks/schemas/triggerSchema.ts | 52 + apps/meseeks/schemas/userSchema.tsx | 23 +- apps/meseeks/scripts/preview-env.ts | 2 +- apps/meseeks/scripts/preview.ts | 26 +- .../skills/builtIn/askForClarification.ts | 21 - apps/meseeks/skills/builtIn/cancelSchedule.ts | 39 - apps/meseeks/skills/builtIn/createSkill.ts | 122 -- apps/meseeks/skills/builtIn/createSubtask.ts | 33 - apps/meseeks/skills/builtIn/decreaseBudget.ts | 33 - apps/meseeks/skills/builtIn/discard.ts | 26 - apps/meseeks/skills/builtIn/divide.ts | 27 - apps/meseeks/skills/builtIn/done.ts | 30 - .../meseeks/skills/builtIn/getSkillDetails.ts | 60 - apps/meseeks/skills/builtIn/increaseBudget.ts | 40 - apps/meseeks/skills/builtIn/index.ts | 68 -- apps/meseeks/skills/builtIn/justSay.ts | 21 - apps/meseeks/skills/builtIn/lookAtMe.ts | 90 -- apps/meseeks/skills/builtIn/moveTask.ts | 34 - apps/meseeks/skills/builtIn/multiply.ts | 26 - apps/meseeks/skills/builtIn/reason.ts | 27 - apps/meseeks/skills/builtIn/render.ts | 37 - apps/meseeks/skills/builtIn/reopen.ts | 21 - apps/meseeks/skills/builtIn/requestBudget.ts | 30 - .../skills/builtIn/requestIteration.ts | 21 - apps/meseeks/skills/builtIn/resolve.ts | 32 - apps/meseeks/skills/builtIn/say.ts | 32 - apps/meseeks/skills/builtIn/schedule.ts | 99 -- .../skills/builtIn/scheduledIteration.ts | 39 - apps/meseeks/skills/builtIn/setUserInfo.ts | 34 - apps/meseeks/skills/builtIn/stop.ts | 15 - apps/meseeks/skills/builtIn/subtract.ts | 27 - apps/meseeks/skills/builtIn/sum.ts | 26 - .../skills/builtIn/updateInstructions.ts | 60 - apps/meseeks/skills/builtIn/updateSkill.ts | 66 - apps/meseeks/skills/createAITool.ts | 343 ------ apps/meseeks/skills/createBuiltInTool.ts | 40 - apps/meseeks/skills/createHttpTool.ts | 174 --- apps/meseeks/skills/createReactions.ts | 29 - apps/meseeks/skills/defineSkill.ts | 40 - apps/meseeks/skills/tools.ts | 97 -- apps/meseeks/src/client.tsx | 2 +- apps/meseeks/src/components/Action.tsx | 24 - .../ActionComposer/ActionComposer.tsx | 215 ---- .../components/ActionComposer/IdleState.tsx | 175 --- .../KeyboardShortcutIndicator.tsx | 28 - .../ActionComposer/RecordingState.tsx | 39 - .../ActionComposer/TranscribingState.tsx | 27 - .../src/components/ActionComposer/index.ts | 4 - .../ActionComposer/strips/BudgetStrip.tsx | 13 - .../ActionComposer/strips/DraftStrip.tsx | 62 - .../ActionComposer/strips/QueueStrip.tsx | 231 ---- .../ActionComposer/strips/StripContainer.tsx | 49 - .../src/components/ActionComposer/types.ts | 12 - apps/meseeks/src/components/ActionIsland.tsx | 231 ---- apps/meseeks/src/components/ActionTest.tsx | 205 ---- .../src/components/AddBudgetButton.tsx | 40 - apps/meseeks/src/components/Balance.tsx | 23 - .../meseeks/src/components/BudgetSelector.tsx | 105 -- .../src/components/CollapsibleSummary.tsx | 34 - apps/meseeks/src/components/DebugAction.tsx | 1082 ----------------- apps/meseeks/src/components/EnergyDrawer.tsx | 103 -- .../meseeks/src/components/FeedbackDialog.tsx | 6 +- apps/meseeks/src/components/Inbox.tsx | 73 -- .../components/Launcher/LauncherContent.tsx | 20 +- .../components/Launcher/LauncherDialog.tsx | 158 +-- .../Launcher/currentTask/Actions.tsx | 28 - .../Launcher/currentTask/AddEnergyItem.tsx | 29 - .../Launcher/currentTask/ClearEnergyItem.tsx | 25 - .../currentTask/DiscardCurrentTaskItem.tsx | 26 - .../Launcher/currentTask/ReopenTaskItem.tsx | 27 - .../currentTask/ResolveCurrentTaskItem.tsx | 25 - .../currentTask/ScheduleIterationItem.tsx | 25 - .../currentTask/StopReactionsItem.tsx | 26 - .../Launcher/quickActions/Section.tsx | 6 +- .../Launcher/shortcuts/BalanceItem.tsx | 6 +- .../Launcher/shortcuts/DevModeItem.tsx | 21 +- .../Launcher/shortcuts/NewTaskItem.tsx | 12 - .../Launcher/shortcuts/SchedulesItem.tsx | 12 - .../components/Launcher/shortcuts/Section.tsx | 14 +- .../Launcher/shortcuts/SeekItem.tsx | 39 - .../Launcher/shortcuts/SkillsItem.tsx | 2 +- .../Launcher/shortcuts/SubscribeItem.tsx | 20 - .../src/components/Launcher/tasks/Section.tsx | 22 - .../Launcher/tasks/TaskResultItem.tsx | 15 - .../components/Launcher/themes/ThemeIcon.tsx | 17 + apps/meseeks/src/components/Launcher/types.ts | 3 - apps/meseeks/src/components/MainHeader.tsx | 83 +- .../meseeks/src/components/QuestionDialog.tsx | 2 +- apps/meseeks/src/components/QuickSeek.tsx | 266 ---- .../src/components/RenderActionTestSuite.tsx | 700 ----------- apps/meseeks/src/components/ReopenButton.tsx | 35 - .../components/ScheduleIterationDialog.tsx | 341 ------ apps/meseeks/src/components/SkillsLink.tsx | 21 +- apps/meseeks/src/components/SubtaskList.tsx | 46 - .../src/components/TaskAvailableSkills.tsx | 164 --- apps/meseeks/src/components/TaskBudget.tsx | 145 --- .../src/components/TaskConversation.tsx | 286 ----- .../components/TaskConversationActions.tsx | 132 -- apps/meseeks/src/components/TaskDetail.tsx | 108 -- apps/meseeks/src/components/TaskItem.tsx | 152 --- apps/meseeks/src/components/TaskSchedules.tsx | 48 - .../src/components/TaskStatusIndicator.tsx | 25 - .../TestConsole/ActionDetailsPanel.tsx | 303 +++++ .../TestConsole/CompiledStatePanel.tsx | 183 +++ .../TestConsole/ConversationPanel.tsx | 314 +++++ .../src/components/TestConsole/DiffViewer.tsx | 178 +++ .../TestConsole/DirectoryWorkbench.tsx | 518 ++++++++ .../components/TestConsole/ExplorerPanel.tsx | 151 +++ .../components/TestConsole/FileWorkbench.tsx | 662 ++++++++++ .../TestConsole/FilesystemRoute.tsx | 72 ++ .../components/TestConsole/InspectorPanel.tsx | 97 ++ .../components/TestConsole/ReactorPanel.tsx | 208 ++++ .../src/components/TestConsole/References.tsx | 45 + .../components/TestConsole/TestConsole.tsx | 200 +++ .../components/TestConsole/TriggerPanel.tsx | 131 ++ .../src/components/TestConsole/path.ts | 69 ++ .../src/components/TestConsole/revisions.ts | 57 + .../src/components/TestConsole/shared.tsx | 52 + apps/meseeks/src/components/ThemeProvider.tsx | 55 +- apps/meseeks/src/components/TopUpCard.tsx | 2 - apps/meseeks/src/components/TopUpItem.tsx | 10 +- apps/meseeks/src/components/UserMenuItem.tsx | 4 +- .../src/components/actions/AnalyzeAction.tsx | 242 ---- .../actions/CancelScheduleAction.tsx | 36 - .../src/components/actions/ComposeAction.tsx | 34 - .../src/components/actions/DiscardAction.tsx | 23 - .../src/components/actions/DoneAction.tsx | 19 - .../src/components/actions/GenericAction.tsx | 295 ----- .../actions/GetSkillDetailsAction.tsx | 39 - .../actions/IncreaseBudgetAction.tsx | 49 - .../src/components/actions/LearnAction.tsx | 45 - .../src/components/actions/MathAction.tsx | 9 - .../src/components/actions/ReasonAction.tsx | 30 - .../src/components/actions/RenderAction.tsx | 151 --- .../actions/RenderActionControls.tsx | 146 --- .../src/components/actions/ReopenAction.tsx | 23 - .../actions/RequestBudgetAction.tsx | 71 -- .../src/components/actions/ResolveAction.tsx | 11 - .../src/components/actions/SayAction.tsx | 229 ---- .../src/components/actions/ScheduleAction.tsx | 37 - .../components/actions/ScrapeLinkAction.tsx | 162 --- .../components/actions/ScrapeTweetAction.tsx | 65 - .../components/actions/SearchPlacesAction.tsx | 113 -- .../components/actions/SearchWebAction.tsx | 161 --- .../components/actions/SetUserInfoAction.tsx | 73 -- .../src/components/actions/SkillAction.tsx | 173 --- .../src/components/actions/StopAction.tsx | 3 - .../src/components/actions/ThinkingAction.tsx | 24 - .../actions/TwitterSearchAction.tsx | 118 -- .../actions/UpdateInstructionsAction.tsx | 87 -- .../components/actions/ValyuSearchAction.tsx | 276 ----- apps/meseeks/src/components/actions/index.ts | 81 -- .../actions/search/SearchResultsPanel.tsx | 246 ---- .../src/components/balance/ActiveTaskItem.tsx | 77 -- .../src/components/balance/ActiveTasksTab.tsx | 48 - .../components/balance/LowBalanceWarning.tsx | 25 - .../src/components/balance/TopUpSection.tsx | 47 - .../components/balance/TransactionItem.tsx | 128 -- .../components/balance/TransactionsTab.tsx | 79 -- apps/meseeks/src/components/layout/Task.tsx | 137 --- .../layout/TaskDetailAndConversation.tsx | 72 -- .../src/components/schedules/ScheduleCard.tsx | 90 -- .../src/components/schedules/ScheduleItem.tsx | 160 --- .../src/components/skills/HardSkillConfig.tsx | 137 +-- .../src/components/skills/HardSkillForm.tsx | 2 - .../components/skills/InnateSkillDetail.tsx | 92 -- .../components/skills/InnateSkillsList.tsx | 64 - .../src/components/skills/InstinctList.tsx | 35 + .../skills/ShareSkillRequestDialog.tsx | 4 +- .../src/components/skills/SkillCard.tsx | 83 +- .../skills/SkillLearningInfoBox.tsx | 2 +- .../components/skills/SkillListContent.tsx | 30 +- .../src/components/skills/SoftSkillConfig.tsx | 8 +- .../src/components/skills/SoftSkillForm.tsx | 4 +- .../skills/shared/BasicSkillFields.tsx | 6 +- .../skills/shared/SkillCommandList.tsx | 12 +- .../src/components/subscribe/ExternalLink.tsx | 10 - .../src/components/subscribe/FeatureItem.tsx | 18 - .../src/components/subscribe/FounderCard.tsx | 45 - .../src/components/subscribe/ProCard.tsx | 44 - .../components/subscribe/SubscribePage.tsx | 63 - .../subscribe/faq/CancellationFaq.tsx | 11 - .../components/subscribe/faq/CreditsFaq.tsx | 26 - .../subscribe/faq/EnterpriseFaq.tsx | 123 -- .../components/subscribe/faq/FaqSection.tsx | 31 - .../subscribe/faq/FounderPackageFaq.tsx | 33 - .../subscribe/faq/FreeAlternativeFaq.tsx | 25 - .../components/subscribe/faq/SafetyFaq.tsx | 50 - .../subscribe/faq/SkillsAndLoopsFaq.tsx | 42 - .../subscribe/faq/UnlimitedUsageFaq.tsx | 15 - .../src/components/subscribe/faq/index.ts | 22 - .../meseeks/src/components/subscribe/index.ts | 4 - apps/meseeks/src/components/ui/mdx.tsx | 21 - apps/meseeks/src/hooks/preferences/index.ts | 5 - .../preferences/useEnabledSkillsPreference.ts | 18 - .../useInboxWidthPercentPreference.ts | 16 - .../hooks/preferences/usePreferenceValue.ts | 18 - .../src/hooks/preferences/useSetPreference.ts | 21 - .../useTaskDetailVisiblePreference.ts | 16 - ...TaskDetailWidthPercentDesktopPreference.ts | 16 - .../useTaskListVisiblePreference.ts | 16 - .../src/hooks/query/useActionDetails.ts | 15 - .../meseeks/src/hooks/query/useComposition.ts | 14 - apps/meseeks/src/hooks/query/useSchedules.ts | 26 - apps/meseeks/src/hooks/query/useSkills.ts | 23 +- apps/meseeks/src/hooks/query/useSubtasks.ts | 50 - apps/meseeks/src/hooks/query/useTask.ts | 15 - apps/meseeks/src/hooks/query/useTopUps.ts | 2 +- .../src/hooks/query/useTransactions.ts | 14 - apps/meseeks/src/hooks/useAct.ts | 11 +- apps/meseeks/src/hooks/useCancelSchedule.ts | 25 - apps/meseeks/src/hooks/useComponentShare.ts | 40 - apps/meseeks/src/hooks/useComposer.tsx | 422 ------- apps/meseeks/src/hooks/useCurrentTask.ts | 17 - apps/meseeks/src/hooks/useDraftSync.ts | 185 --- apps/meseeks/src/hooks/useIsPro.ts | 13 - .../src/hooks/useOptimisticTaskUpdate.ts | 73 -- apps/meseeks/src/hooks/usePayment.ts | 7 +- apps/meseeks/src/hooks/useScheduleDialog.tsx | 43 - .../src/hooks/useSetupWindowGlobals.ts | 106 -- apps/meseeks/src/hooks/useSplatParams.ts | 18 - .../src/hooks/useSuspensePaginatedQuery.ts | 69 -- apps/meseeks/src/hooks/useTaskMutations.ts | 483 -------- apps/meseeks/src/hooks/useVoiceRecording.ts | 110 -- apps/meseeks/src/lib/seo.ts | 2 +- apps/meseeks/src/lib/skill-form-utils.ts | 145 ++- .../meseeks/src/lib/taskItemTimestamp.test.ts | 123 -- apps/meseeks/src/lib/taskItemTimestamp.ts | 144 --- apps/meseeks/src/lib/upload.ts | 234 ++++ apps/meseeks/src/routeTree.gen.ts | 202 +-- apps/meseeks/src/routes/$.tsx | 33 +- apps/meseeks/src/routes/__root.tsx | 29 +- apps/meseeks/src/routes/action_.$id.tsx | 51 - apps/meseeks/src/routes/api/auth/$.ts | 2 + .../src/routes/api/translate/session.ts | 111 -- apps/meseeks/src/routes/balance.tsx | 101 -- apps/meseeks/src/routes/polar/subscribed.tsx | 12 - apps/meseeks/src/routes/schedules.tsx | 40 - apps/meseeks/src/routes/share_.$id.tsx | 42 - apps/meseeks/src/routes/skills.tsx | 82 +- apps/meseeks/src/routes/skills_.$id.tsx | 82 +- .../src/routes/skills_.innate_.$key.tsx | 65 - apps/meseeks/src/routes/skills_.new.tsx | 47 +- apps/meseeks/src/routes/subscribe.tsx | 6 - apps/meseeks/src/routes/top-up.tsx | 5 - apps/meseeks/src/routes/top-up_.$id.tsx | 33 +- apps/meseeks/src/routes/translate.tsx | 825 ------------- apps/meseeks/src/routes/wallet.tsx | 70 ++ bun.lock | 124 +- docs/README.md | 29 + docs/architecture.md | 486 ++++++++ docs/data-model.md | 438 +++++++ docs/debts.md | 179 +++ docs/decisions.md | 181 +++ docs/vocabulary.md | 191 +++ .../_index.mdx | 44 +- packages/ui/src/card.tsx | 2 +- 355 files changed, 11897 insertions(+), 23233 deletions(-) delete mode 100644 apps/meseeks/convex/action.private.ts delete mode 100644 apps/meseeks/convex/action.ts create mode 100644 apps/meseeks/convex/actions.private.ts create mode 100644 apps/meseeks/convex/actions.ts create mode 100644 apps/meseeks/convex/compile.private.ts delete mode 100644 apps/meseeks/convex/components.private.ts delete mode 100644 apps/meseeks/convex/components.ts delete mode 100644 apps/meseeks/convex/drafts.private.ts delete mode 100644 apps/meseeks/convex/drafts.ts create mode 100644 apps/meseeks/convex/execute.private.ts create mode 100644 apps/meseeks/convex/files.private.ts create mode 100644 apps/meseeks/convex/files.ts create mode 100644 apps/meseeks/convex/instincts/commitUpload.private.ts create mode 100644 apps/meseeks/convex/instincts/compile.private.ts create mode 100644 apps/meseeks/convex/instincts/create.private.ts create mode 100644 apps/meseeks/convex/instincts/createTrigger.private.ts create mode 100644 apps/meseeks/convex/instincts/disableTrigger.private.ts create mode 100644 apps/meseeks/convex/instincts/execute.private.ts create mode 100644 apps/meseeks/convex/instincts/index.private.ts create mode 100644 apps/meseeks/convex/instincts/interrupt.private.ts create mode 100644 apps/meseeks/convex/instincts/move.private.ts create mode 100644 apps/meseeks/convex/instincts/prepareUpload.private.ts create mode 100644 apps/meseeks/convex/instincts/request.private.ts create mode 100644 apps/meseeks/convex/instincts/say.private.ts create mode 100644 apps/meseeks/convex/instincts/seed.private.ts create mode 100644 apps/meseeks/convex/instincts/tag.private.ts create mode 100644 apps/meseeks/convex/instincts/think.private.ts create mode 100644 apps/meseeks/convex/instincts/untag.private.ts create mode 100644 apps/meseeks/convex/instincts/write.private.ts delete mode 100644 apps/meseeks/convex/magicRock.tsx create mode 100644 apps/meseeks/convex/pages.private.ts create mode 100644 apps/meseeks/convex/pages.ts create mode 100644 apps/meseeks/convex/polarEvents.private.ts create mode 100644 apps/meseeks/convex/polarEvents.ts delete mode 100644 apps/meseeks/convex/reactor.constants.ts create mode 100644 apps/meseeks/convex/reactor/apply.private.ts create mode 100644 apps/meseeks/convex/reactor/claim.private.ts create mode 100644 apps/meseeks/convex/reactor/perform.private.ts create mode 100644 apps/meseeks/convex/reactor/settle.private.ts create mode 100644 apps/meseeks/convex/reactor/stage.private.ts create mode 100644 apps/meseeks/convex/request.private.ts delete mode 100644 apps/meseeks/convex/schedule/lifecycle.private.ts delete mode 100644 apps/meseeks/convex/schedule/lifecycle.ts delete mode 100644 apps/meseeks/convex/schedules.private.ts delete mode 100644 apps/meseeks/convex/schedules.ts delete mode 100644 apps/meseeks/convex/seed.ts create mode 100644 apps/meseeks/convex/storage.private.ts delete mode 100644 apps/meseeks/convex/subscriptions.private.ts delete mode 100644 apps/meseeks/convex/subscriptions.ts delete mode 100644 apps/meseeks/convex/tasks.private.ts delete mode 100644 apps/meseeks/convex/tasks.ts delete mode 100644 apps/meseeks/convex/transactions.ts create mode 100644 apps/meseeks/convex/triggers.private.ts create mode 100644 apps/meseeks/convex/triggers.ts delete mode 100644 apps/meseeks/convex/users/preferences.private.ts delete mode 100644 apps/meseeks/convex/users/preferences.ts delete mode 100644 apps/meseeks/convex/users/themes.ts delete mode 100644 apps/meseeks/lib/cron.ts create mode 100644 apps/meseeks/lib/instinct.ts create mode 100644 apps/meseeks/schemas/boxSchema.ts delete mode 100644 apps/meseeks/schemas/componentSchema.tsx delete mode 100644 apps/meseeks/schemas/draftSchema.ts create mode 100644 apps/meseeks/schemas/fileRevisionSchema.ts create mode 100644 apps/meseeks/schemas/fileSchema.ts create mode 100644 apps/meseeks/schemas/pageSchema.ts create mode 100644 apps/meseeks/schemas/reactorSchema.ts delete mode 100644 apps/meseeks/schemas/scheduleSchema.ts delete mode 100644 apps/meseeks/schemas/subscriptionSchema.tsx delete mode 100644 apps/meseeks/schemas/taskSchema.tsx create mode 100644 apps/meseeks/schemas/triggerSchema.ts delete mode 100644 apps/meseeks/skills/builtIn/askForClarification.ts delete mode 100644 apps/meseeks/skills/builtIn/cancelSchedule.ts delete mode 100644 apps/meseeks/skills/builtIn/createSkill.ts delete mode 100644 apps/meseeks/skills/builtIn/createSubtask.ts delete mode 100644 apps/meseeks/skills/builtIn/decreaseBudget.ts delete mode 100644 apps/meseeks/skills/builtIn/discard.ts delete mode 100644 apps/meseeks/skills/builtIn/divide.ts delete mode 100644 apps/meseeks/skills/builtIn/done.ts delete mode 100644 apps/meseeks/skills/builtIn/getSkillDetails.ts delete mode 100644 apps/meseeks/skills/builtIn/increaseBudget.ts delete mode 100644 apps/meseeks/skills/builtIn/index.ts delete mode 100644 apps/meseeks/skills/builtIn/justSay.ts delete mode 100644 apps/meseeks/skills/builtIn/lookAtMe.ts delete mode 100644 apps/meseeks/skills/builtIn/moveTask.ts delete mode 100644 apps/meseeks/skills/builtIn/multiply.ts delete mode 100644 apps/meseeks/skills/builtIn/reason.ts delete mode 100644 apps/meseeks/skills/builtIn/render.ts delete mode 100644 apps/meseeks/skills/builtIn/reopen.ts delete mode 100644 apps/meseeks/skills/builtIn/requestBudget.ts delete mode 100644 apps/meseeks/skills/builtIn/requestIteration.ts delete mode 100644 apps/meseeks/skills/builtIn/resolve.ts delete mode 100644 apps/meseeks/skills/builtIn/say.ts delete mode 100644 apps/meseeks/skills/builtIn/schedule.ts delete mode 100644 apps/meseeks/skills/builtIn/scheduledIteration.ts delete mode 100644 apps/meseeks/skills/builtIn/setUserInfo.ts delete mode 100644 apps/meseeks/skills/builtIn/stop.ts delete mode 100644 apps/meseeks/skills/builtIn/subtract.ts delete mode 100644 apps/meseeks/skills/builtIn/sum.ts delete mode 100644 apps/meseeks/skills/builtIn/updateInstructions.ts delete mode 100644 apps/meseeks/skills/builtIn/updateSkill.ts delete mode 100644 apps/meseeks/skills/createAITool.ts delete mode 100644 apps/meseeks/skills/createBuiltInTool.ts delete mode 100644 apps/meseeks/skills/createHttpTool.ts delete mode 100644 apps/meseeks/skills/createReactions.ts delete mode 100644 apps/meseeks/skills/defineSkill.ts delete mode 100644 apps/meseeks/skills/tools.ts delete mode 100644 apps/meseeks/src/components/Action.tsx delete mode 100644 apps/meseeks/src/components/ActionComposer/ActionComposer.tsx delete mode 100644 apps/meseeks/src/components/ActionComposer/IdleState.tsx delete mode 100644 apps/meseeks/src/components/ActionComposer/KeyboardShortcutIndicator.tsx delete mode 100644 apps/meseeks/src/components/ActionComposer/RecordingState.tsx delete mode 100644 apps/meseeks/src/components/ActionComposer/TranscribingState.tsx delete mode 100644 apps/meseeks/src/components/ActionComposer/index.ts delete mode 100644 apps/meseeks/src/components/ActionComposer/strips/BudgetStrip.tsx delete mode 100644 apps/meseeks/src/components/ActionComposer/strips/DraftStrip.tsx delete mode 100644 apps/meseeks/src/components/ActionComposer/strips/QueueStrip.tsx delete mode 100644 apps/meseeks/src/components/ActionComposer/strips/StripContainer.tsx delete mode 100644 apps/meseeks/src/components/ActionComposer/types.ts delete mode 100644 apps/meseeks/src/components/ActionIsland.tsx delete mode 100644 apps/meseeks/src/components/ActionTest.tsx delete mode 100644 apps/meseeks/src/components/AddBudgetButton.tsx delete mode 100644 apps/meseeks/src/components/Balance.tsx delete mode 100644 apps/meseeks/src/components/BudgetSelector.tsx delete mode 100644 apps/meseeks/src/components/CollapsibleSummary.tsx delete mode 100644 apps/meseeks/src/components/DebugAction.tsx delete mode 100644 apps/meseeks/src/components/EnergyDrawer.tsx delete mode 100644 apps/meseeks/src/components/Inbox.tsx delete mode 100644 apps/meseeks/src/components/Launcher/currentTask/Actions.tsx delete mode 100644 apps/meseeks/src/components/Launcher/currentTask/AddEnergyItem.tsx delete mode 100644 apps/meseeks/src/components/Launcher/currentTask/ClearEnergyItem.tsx delete mode 100644 apps/meseeks/src/components/Launcher/currentTask/DiscardCurrentTaskItem.tsx delete mode 100644 apps/meseeks/src/components/Launcher/currentTask/ReopenTaskItem.tsx delete mode 100644 apps/meseeks/src/components/Launcher/currentTask/ResolveCurrentTaskItem.tsx delete mode 100644 apps/meseeks/src/components/Launcher/currentTask/ScheduleIterationItem.tsx delete mode 100644 apps/meseeks/src/components/Launcher/currentTask/StopReactionsItem.tsx delete mode 100644 apps/meseeks/src/components/Launcher/shortcuts/NewTaskItem.tsx delete mode 100644 apps/meseeks/src/components/Launcher/shortcuts/SchedulesItem.tsx delete mode 100644 apps/meseeks/src/components/Launcher/shortcuts/SeekItem.tsx delete mode 100644 apps/meseeks/src/components/Launcher/shortcuts/SubscribeItem.tsx delete mode 100644 apps/meseeks/src/components/Launcher/tasks/Section.tsx delete mode 100644 apps/meseeks/src/components/Launcher/tasks/TaskResultItem.tsx delete mode 100644 apps/meseeks/src/components/QuickSeek.tsx delete mode 100644 apps/meseeks/src/components/RenderActionTestSuite.tsx delete mode 100644 apps/meseeks/src/components/ReopenButton.tsx delete mode 100644 apps/meseeks/src/components/ScheduleIterationDialog.tsx delete mode 100644 apps/meseeks/src/components/SubtaskList.tsx delete mode 100644 apps/meseeks/src/components/TaskAvailableSkills.tsx delete mode 100644 apps/meseeks/src/components/TaskBudget.tsx delete mode 100644 apps/meseeks/src/components/TaskConversation.tsx delete mode 100644 apps/meseeks/src/components/TaskConversationActions.tsx delete mode 100644 apps/meseeks/src/components/TaskDetail.tsx delete mode 100644 apps/meseeks/src/components/TaskItem.tsx delete mode 100644 apps/meseeks/src/components/TaskSchedules.tsx delete mode 100644 apps/meseeks/src/components/TaskStatusIndicator.tsx create mode 100644 apps/meseeks/src/components/TestConsole/ActionDetailsPanel.tsx create mode 100644 apps/meseeks/src/components/TestConsole/CompiledStatePanel.tsx create mode 100644 apps/meseeks/src/components/TestConsole/ConversationPanel.tsx create mode 100644 apps/meseeks/src/components/TestConsole/DiffViewer.tsx create mode 100644 apps/meseeks/src/components/TestConsole/DirectoryWorkbench.tsx create mode 100644 apps/meseeks/src/components/TestConsole/ExplorerPanel.tsx create mode 100644 apps/meseeks/src/components/TestConsole/FileWorkbench.tsx create mode 100644 apps/meseeks/src/components/TestConsole/FilesystemRoute.tsx create mode 100644 apps/meseeks/src/components/TestConsole/InspectorPanel.tsx create mode 100644 apps/meseeks/src/components/TestConsole/ReactorPanel.tsx create mode 100644 apps/meseeks/src/components/TestConsole/References.tsx create mode 100644 apps/meseeks/src/components/TestConsole/TestConsole.tsx create mode 100644 apps/meseeks/src/components/TestConsole/TriggerPanel.tsx create mode 100644 apps/meseeks/src/components/TestConsole/path.ts create mode 100644 apps/meseeks/src/components/TestConsole/revisions.ts create mode 100644 apps/meseeks/src/components/TestConsole/shared.tsx delete mode 100644 apps/meseeks/src/components/actions/AnalyzeAction.tsx delete mode 100644 apps/meseeks/src/components/actions/CancelScheduleAction.tsx delete mode 100644 apps/meseeks/src/components/actions/ComposeAction.tsx delete mode 100644 apps/meseeks/src/components/actions/DiscardAction.tsx delete mode 100644 apps/meseeks/src/components/actions/DoneAction.tsx delete mode 100644 apps/meseeks/src/components/actions/GenericAction.tsx delete mode 100644 apps/meseeks/src/components/actions/GetSkillDetailsAction.tsx delete mode 100644 apps/meseeks/src/components/actions/IncreaseBudgetAction.tsx delete mode 100644 apps/meseeks/src/components/actions/LearnAction.tsx delete mode 100644 apps/meseeks/src/components/actions/MathAction.tsx delete mode 100644 apps/meseeks/src/components/actions/ReasonAction.tsx delete mode 100644 apps/meseeks/src/components/actions/RenderAction.tsx delete mode 100644 apps/meseeks/src/components/actions/RenderActionControls.tsx delete mode 100644 apps/meseeks/src/components/actions/ReopenAction.tsx delete mode 100644 apps/meseeks/src/components/actions/RequestBudgetAction.tsx delete mode 100644 apps/meseeks/src/components/actions/ResolveAction.tsx delete mode 100644 apps/meseeks/src/components/actions/SayAction.tsx delete mode 100644 apps/meseeks/src/components/actions/ScheduleAction.tsx delete mode 100644 apps/meseeks/src/components/actions/ScrapeLinkAction.tsx delete mode 100644 apps/meseeks/src/components/actions/ScrapeTweetAction.tsx delete mode 100644 apps/meseeks/src/components/actions/SearchPlacesAction.tsx delete mode 100644 apps/meseeks/src/components/actions/SearchWebAction.tsx delete mode 100644 apps/meseeks/src/components/actions/SetUserInfoAction.tsx delete mode 100644 apps/meseeks/src/components/actions/SkillAction.tsx delete mode 100644 apps/meseeks/src/components/actions/StopAction.tsx delete mode 100644 apps/meseeks/src/components/actions/ThinkingAction.tsx delete mode 100644 apps/meseeks/src/components/actions/TwitterSearchAction.tsx delete mode 100644 apps/meseeks/src/components/actions/UpdateInstructionsAction.tsx delete mode 100644 apps/meseeks/src/components/actions/ValyuSearchAction.tsx delete mode 100644 apps/meseeks/src/components/actions/index.ts delete mode 100644 apps/meseeks/src/components/actions/search/SearchResultsPanel.tsx delete mode 100644 apps/meseeks/src/components/balance/ActiveTaskItem.tsx delete mode 100644 apps/meseeks/src/components/balance/ActiveTasksTab.tsx delete mode 100644 apps/meseeks/src/components/balance/LowBalanceWarning.tsx delete mode 100644 apps/meseeks/src/components/balance/TopUpSection.tsx delete mode 100644 apps/meseeks/src/components/balance/TransactionItem.tsx delete mode 100644 apps/meseeks/src/components/balance/TransactionsTab.tsx delete mode 100644 apps/meseeks/src/components/layout/Task.tsx delete mode 100644 apps/meseeks/src/components/layout/TaskDetailAndConversation.tsx delete mode 100644 apps/meseeks/src/components/schedules/ScheduleCard.tsx delete mode 100644 apps/meseeks/src/components/schedules/ScheduleItem.tsx delete mode 100644 apps/meseeks/src/components/skills/InnateSkillDetail.tsx delete mode 100644 apps/meseeks/src/components/skills/InnateSkillsList.tsx create mode 100644 apps/meseeks/src/components/skills/InstinctList.tsx delete mode 100644 apps/meseeks/src/components/subscribe/ExternalLink.tsx delete mode 100644 apps/meseeks/src/components/subscribe/FeatureItem.tsx delete mode 100644 apps/meseeks/src/components/subscribe/FounderCard.tsx delete mode 100644 apps/meseeks/src/components/subscribe/ProCard.tsx delete mode 100644 apps/meseeks/src/components/subscribe/SubscribePage.tsx delete mode 100644 apps/meseeks/src/components/subscribe/faq/CancellationFaq.tsx delete mode 100644 apps/meseeks/src/components/subscribe/faq/CreditsFaq.tsx delete mode 100644 apps/meseeks/src/components/subscribe/faq/EnterpriseFaq.tsx delete mode 100644 apps/meseeks/src/components/subscribe/faq/FaqSection.tsx delete mode 100644 apps/meseeks/src/components/subscribe/faq/FounderPackageFaq.tsx delete mode 100644 apps/meseeks/src/components/subscribe/faq/FreeAlternativeFaq.tsx delete mode 100644 apps/meseeks/src/components/subscribe/faq/SafetyFaq.tsx delete mode 100644 apps/meseeks/src/components/subscribe/faq/SkillsAndLoopsFaq.tsx delete mode 100644 apps/meseeks/src/components/subscribe/faq/UnlimitedUsageFaq.tsx delete mode 100644 apps/meseeks/src/components/subscribe/faq/index.ts delete mode 100644 apps/meseeks/src/components/subscribe/index.ts delete mode 100644 apps/meseeks/src/hooks/preferences/index.ts delete mode 100644 apps/meseeks/src/hooks/preferences/useEnabledSkillsPreference.ts delete mode 100644 apps/meseeks/src/hooks/preferences/useInboxWidthPercentPreference.ts delete mode 100644 apps/meseeks/src/hooks/preferences/usePreferenceValue.ts delete mode 100644 apps/meseeks/src/hooks/preferences/useSetPreference.ts delete mode 100644 apps/meseeks/src/hooks/preferences/useTaskDetailVisiblePreference.ts delete mode 100644 apps/meseeks/src/hooks/preferences/useTaskDetailWidthPercentDesktopPreference.ts delete mode 100644 apps/meseeks/src/hooks/preferences/useTaskListVisiblePreference.ts delete mode 100644 apps/meseeks/src/hooks/query/useActionDetails.ts delete mode 100644 apps/meseeks/src/hooks/query/useComposition.ts delete mode 100644 apps/meseeks/src/hooks/query/useSchedules.ts delete mode 100644 apps/meseeks/src/hooks/query/useSubtasks.ts delete mode 100644 apps/meseeks/src/hooks/query/useTask.ts delete mode 100644 apps/meseeks/src/hooks/query/useTransactions.ts delete mode 100644 apps/meseeks/src/hooks/useCancelSchedule.ts delete mode 100644 apps/meseeks/src/hooks/useComponentShare.ts delete mode 100644 apps/meseeks/src/hooks/useComposer.tsx delete mode 100644 apps/meseeks/src/hooks/useCurrentTask.ts delete mode 100644 apps/meseeks/src/hooks/useDraftSync.ts delete mode 100644 apps/meseeks/src/hooks/useIsPro.ts delete mode 100644 apps/meseeks/src/hooks/useOptimisticTaskUpdate.ts delete mode 100644 apps/meseeks/src/hooks/useScheduleDialog.tsx delete mode 100644 apps/meseeks/src/hooks/useSetupWindowGlobals.ts delete mode 100644 apps/meseeks/src/hooks/useSplatParams.ts delete mode 100644 apps/meseeks/src/hooks/useSuspensePaginatedQuery.ts delete mode 100644 apps/meseeks/src/hooks/useTaskMutations.ts delete mode 100644 apps/meseeks/src/hooks/useVoiceRecording.ts delete mode 100644 apps/meseeks/src/lib/taskItemTimestamp.test.ts delete mode 100644 apps/meseeks/src/lib/taskItemTimestamp.ts create mode 100644 apps/meseeks/src/lib/upload.ts delete mode 100644 apps/meseeks/src/routes/action_.$id.tsx delete mode 100644 apps/meseeks/src/routes/api/translate/session.ts delete mode 100644 apps/meseeks/src/routes/balance.tsx delete mode 100644 apps/meseeks/src/routes/polar/subscribed.tsx delete mode 100644 apps/meseeks/src/routes/schedules.tsx delete mode 100644 apps/meseeks/src/routes/share_.$id.tsx delete mode 100644 apps/meseeks/src/routes/skills_.innate_.$key.tsx delete mode 100644 apps/meseeks/src/routes/subscribe.tsx delete mode 100644 apps/meseeks/src/routes/translate.tsx create mode 100644 apps/meseeks/src/routes/wallet.tsx create mode 100644 docs/README.md create mode 100644 docs/architecture.md create mode 100644 docs/data-model.md create mode 100644 docs/debts.md create mode 100644 docs/decisions.md create mode 100644 docs/vocabulary.md diff --git a/.config/MasterPlan.md b/.config/MasterPlan.md index 502efaeb..cdb95975 100644 --- a/.config/MasterPlan.md +++ b/.config/MasterPlan.md @@ -109,6 +109,10 @@ We do not use or support anything Microsoft. This is a GLOBAL STRICT UNNEGOTIABL - Prefer short, exact names over verbose names. Do not repeat the containing concept in the identifier. - bad: `task.loopBinding`, `reactionTrigger`, `buildMagicRockContextFromActionDetails`, `SeekLoop@12` - good: `task.loop`, `trigger`, `buildContext({ details })`, `Seek@12` +- If a field value is an id but the schema cannot type it with `zid(...)`, include `Id` in the field name. + - bad: serialized skill input `{ file: "..." }` when `file` is only `z.string()` + - good: serialized skill input `{ fileId: "..." }`, then parse with `zid('files')` at the server boundary + - good: typed Convex args may still use semantic names like `file: zid('files')` ### Ternaries @@ -291,6 +295,16 @@ Do not run `bunx convex deploy` - this deploys to production. - Always use indexes for queries - Never use `filter()` without explicit user consent - Use `zid('tableName')` for typed IDs +- Persist domain rows through their owning Convex module. Other modules call semantic helpers instead of writing foreign tables directly. + - bad: `topUps.private.ts` writes the transactions table directly + - good: `topUps.private.ts` calls `addTopUpTransaction(...)` from `transactions.private.ts` +- Add schema fields and indexes only for known current reads/writes. They are maintenance cost, not free preparedness. +- Use `author` for row causality/authorship. Do not invent `createdBy` or `updatedBy` fields unless a current workflow needs those exact fields. + - bad: add `createdBy` and `updatedBy` to a table because they sound complete + - good: store `author` when that is the actual causality field, and add later fields only when a real workflow needs them +- When a schema concept is renamed, apply the same name across similar schemas and docs in the same pass. If the rename conflicts with another settled term, stop and call out the conflict. + - bad: use `directory` in actions and `root` in boxes for the same action runtime boundary + - good: use `root` consistently when the field means "where this action/runtime runs" ### Environment Variables @@ -300,7 +314,7 @@ Do not run `bunx convex deploy` - this deploys to production. - bad: run Convex scripts through `bunx convex@latest` or another CLI version that can drift away from the app dependency - good: run `bun preview`, then run `bun dev:web` in the terminal/session that should stay open - good: run Convex scripts through the app-installed `convex` binary so CLI behavior and app runtime stay on the same package version - - preview seed runs after the first successful deploy for a worktree/branch and is tracked in `apps/meseeks/.env.local`; set `CONVEX_PREVIEW_RUN=none` only when the preview should not be seeded + - preview runs can execute `CONVEX_PREVIEW_RUN` after the first successful deploy for a worktree/branch and are tracked in `apps/meseeks/.env.local`; the default is `none` until a real seed exists - Never run `bunx convex env set`, `bunx convex env unset`, or similar Convex env mutation commands unless the user explicitly asks. If backend code needs a new Convex env var, tell the user the exact variable name, where it is read, and ask them to set it. - bad: silently add `BETTER_AUTH_SECRET` to `.env.local` or mutate Convex envs during a migration - good: say `convex/auth.ts` now reads `BETTER_AUTH_SECRET`; ask the user to set it in the Convex environment they own @@ -310,6 +324,7 @@ Do not run `bunx convex deploy` - this deploys to production. ### Types - Use Zod schemas for all custom types +- When docs define schema field descriptions, mirror those descriptions in the Zod schema with `.describe()` unless the field name and type already fully describe the field. - Avoid rewriting schemas - import and use `z.infer()` - Use generated types: `Doc<'tableName'>`, `Id<'tableName'>` - Never re-declare enum/literal sets that already exist in domain schemas; derive from existing exported schemas. @@ -358,6 +373,17 @@ One file per hook in `src/hooks/`. - If a named skill is not loaded in the Codex app, check repo-local `.config/skills//SKILL.md` before claiming it cannot be used. Use the on-disk source manually when present. - If the user invokes a repo skill by name or path, run that skill workflow instead of only acknowledging it. +## Documentation + +- Root `docs/` is the canonical technical documentation for the product architecture, runtime behavior, schemas, APIs, provider integrations, operations, security boundaries, and file conventions. +- At the start of every substantial round, read the entire `docs/` tree before changing code or docs. Do not rely on targeted searches as a substitute for reading the docs. +- Before the final response of every substantial round, do a full documentation pass: reread the entire `docs/` tree, update anything made stale by the round, and resolve conflicts. Targeted searches are useful only after the full read. +- Docs may be written before code when they describe the implementation being built in the same round. If the matching code does not land in that round, clearly mark the behavior as planned, deferred, or not implemented. +- If code and docs conflict, fix the conflict in the same round or stop and report the mismatch directly. Docs that lie are worse than no docs. +- Update `.config/MasterPlan.md` when a round changes agent behavior or repo rules. After changing MasterPlan, run `bun run config:build` so generated assistant instructions stay in sync. +- Product docs describe the product, not branch strategy, migration status, implementation plans, or agent instructions. Temporary plans belong in chat or a non-docs planning artifact. +- Treat PRO as the product root. Do not create `docs/pro/`, `components/pro/`, `api.pro`, or prose that frames PRO as a sub-area inside another product model unless the user explicitly introduces a new boundary. + ## Making Changes - Every line of code has a maintenance cost. Crush unnecessary complexity. @@ -381,6 +407,7 @@ One file per hook in `src/hooks/`. - If a file is already staged and you edit it again, preserve the user's staged snapshot and leave your new edits unstaged so the user can review the small follow-up diff with `git diff`. - bad: user stages a large prompt rewrite, asks for one small follow-up, and the assistant makes the staged diff include the follow-up too - good: the large rewrite stays staged; the follow-up remains an unstaged `MM` delta until the user stages it +- Manual user edits are authoritative. Do not remove, rewrite, or "clean up" user-added text or code just because it looks like it violates an earlier assumption. If it conflicts with instructions, stop and ask; do not silently revert it. - Never drop stashes. Use `git stash apply`, not `git stash pop`, and leave stash entries intact unless the user explicitly says to drop or clear them. - The user may already have a dev account session available for browser checks. If a flow needs sign-in and the session is missing, expired, or lands on auth UI, stop and ask the user to sign in before continuing. - When removing code, review the surrounding context for leftover artifacts (dead variables, unnecessary wrappers, orphaned blank lines) @@ -414,6 +441,9 @@ One file per hook in `src/hooks/`. - Avoid unnecessary layers. Only add wrappers or indirection if they do real work or are truly needed. - bad: pointless config/function wrappers that just forward calls - good: direct, honest factories with clear purpose +- Do not add duplicate runtime schemas to compensate for serializer/tooling limitations. Keep one source schema when possible, document the limitation, and stop if the clean path is blocked. + - bad: define parallel public and runtime schemas only because `zid()` cannot serialize + - good: use one serializable schema with explicit `fileId` fields, parse with `zid()` at the server boundary, and track the serializer limitation as a debt - Before changing an existing workaround patch or local tooling fix, read why it exists and confirm the current task actually requires touching it. If not, leave it alone. - bad: rewrite an existing `patches/*.patch` workaround because a migration regenerated files nearby - good: read the existing patch rationale first and only touch it when the task genuinely depends on changing that workaround @@ -428,6 +458,7 @@ One file per hook in `src/hooks/`. - Never present assumptions as facts; if uncertain, say it's an assumption and verify before claiming behavior - Do not invent justifications (such as "compatibility" or existing constraints) that are not explicitly present in code, docs, or user requirements - Always clearly communicate tradeoffs you make +- If you do something different from an agreed decision because implementation exposed an unexpected obstacle, highlight that deviation in the final answer and explain the obstacle. - If scope shifts or the user says the execution is off-track, restate the exact requested outcome and complete that before proposing extras - Preserve exact user-specified literals (names/tags/phrases/UI labels) when implementing instructions; do not substitute near-synonyms. - bad: user asks for `` and assistant writes `` diff --git a/apps/meseeks/convex/_generated/api.d.ts b/apps/meseeks/convex/_generated/api.d.ts index 94b83bb3..32b020c4 100644 --- a/apps/meseeks/convex/_generated/api.d.ts +++ b/apps/meseeks/convex/_generated/api.d.ts @@ -8,28 +8,21 @@ * @module */ -import type * as action from "../action.js"; import type * as action_details from "../action/details.js"; +import type * as actions from "../actions.js"; import type * as babel from "../babel.js"; import type * as betterAuthTriggers from "../betterAuthTriggers.js"; -import type * as components_ from "../components.js"; -import type * as drafts from "../drafts.js"; +import type * as files from "../files.js"; import type * as http from "../http.js"; -import type * as magicRock from "../magicRock.js"; import type * as migrations from "../migrations.js"; +import type * as pages from "../pages.js"; +import type * as polarEvents from "../polarEvents.js"; import type * as reactor from "../reactor.js"; -import type * as schedule_lifecycle from "../schedule/lifecycle.js"; -import type * as schedules from "../schedules.js"; -import type * as seed from "../seed.js"; import type * as skills from "../skills.js"; -import type * as subscriptions from "../subscriptions.js"; -import type * as tasks from "../tasks.js"; import type * as topUps from "../topUps.js"; -import type * as transactions from "../transactions.js"; +import type * as triggers from "../triggers.js"; import type * as users from "../users.js"; -import type * as users_preferences from "../users/preferences.js"; import type * as users_requests from "../users/requests.js"; -import type * as users_themes from "../users/themes.js"; import type { ApiFromModules, @@ -38,28 +31,21 @@ import type { } from "convex/server"; declare const fullApi: ApiFromModules<{ - action: typeof action; "action/details": typeof action_details; + actions: typeof actions; babel: typeof babel; betterAuthTriggers: typeof betterAuthTriggers; - components: typeof components_; - drafts: typeof drafts; + files: typeof files; http: typeof http; - magicRock: typeof magicRock; migrations: typeof migrations; + pages: typeof pages; + polarEvents: typeof polarEvents; reactor: typeof reactor; - "schedule/lifecycle": typeof schedule_lifecycle; - schedules: typeof schedules; - seed: typeof seed; skills: typeof skills; - subscriptions: typeof subscriptions; - tasks: typeof tasks; topUps: typeof topUps; - transactions: typeof transactions; + triggers: typeof triggers; users: typeof users; - "users/preferences": typeof users_preferences; "users/requests": typeof users_requests; - "users/themes": typeof users_themes; }>; /** diff --git a/apps/meseeks/convex/action.private.ts b/apps/meseeks/convex/action.private.ts deleted file mode 100644 index e5f64910..00000000 --- a/apps/meseeks/convex/action.private.ts +++ /dev/null @@ -1,378 +0,0 @@ -import { zid } from 'convex-helpers/server/zod3'; -import { z } from 'zod/v3'; -import { runNextActionIfNeeded } from './reactor.private'; -import { defineMutation, defineQuery } from 'lib/convex'; -import { NotFound } from 'lib/errors'; -import { actionStatusSchema, pendingActionStatusSchema } from 'schemas/actionSchema'; -import { authorSchema } from 'schemas/authorSchema'; -import { paginationOptionsSchema } from 'schemas/paginationOptionsSchema'; -import { setTaskStatus } from './tasks.private'; -import { MutationCtx, QueryCtx } from 'convex/_generated/server'; -import { Id } from 'convex/_generated/dataModel'; - -export const findAction = defineQuery({ - args: z.object({ - actionId: zid('actions'), - }), - handler: async (ctx, { actionId }) => { - // - const action = await ctx.db.get(actionId); - if (!action) throw NotFound(); - - return action; - }, -}); - -export const findRunningAction = defineQuery({ - args: z.object({ - taskId: zid('tasks'), - }), - handler: async (ctx, { taskId }) => { - // - return await findByStatus(ctx, { taskId, status: 'running' }).first(); - }, -}); - -export const findReactions = defineQuery({ - args: z.object({ - taskId: zid('tasks'), - owner: zid('users'), - status: pendingActionStatusSchema.exclude(['running']), - }), - handler: async (ctx, { taskId, owner, status }) => { - // - return await ctx.db - .query('actions') - .withIndex('by_task_status', (q) => - q - .eq('taskId', taskId) // - .eq('status', status), - ) - .filter((q) => q.neq(q.field('author'), owner)) // author !== owner - .collect(); - }, -}); - -export const stopRunningAction = defineMutation({ - args: z.object({ - taskId: zid('tasks'), - author: authorSchema, - authorIsOwner: z.boolean(), - }), - handler: async (ctx, { taskId, author, authorIsOwner }) => { - // - const action = await findRunningAction(ctx, { taskId }); - if (!action) return; - - await ctx.db.patch(action._id, { - status: 'skipped' as const, - costs: [ - // unlimited interruption costs are covered under the Pro subscription - ], - result: { - text: `stopped by ${authorIsOwner ? 'human' : author}`, - reactions: [], - }, - }); - }, -}); - -// this will skip all pending companion (author !== owner) actions -// running actions won't be stopped -export const skipAllPendingReactions = defineMutation({ - args: z.object({ - taskId: zid('tasks'), - owner: zid('users'), - shouldSkipRunning: z.boolean(), - }), - handler: async (ctx, { taskId, owner, shouldSkipRunning }) => { - // - console.debug('skipping all pending reactions taskId', taskId, 'owner', owner); - - const pendingReactions = await Promise.all([ - findReactions(ctx, { taskId, owner, status: 'enqueued' }), - findReactions(ctx, { taskId, owner, status: 'pending authorization' }), - ]).then(([A, B]) => A.concat(B)); - - console.debug('pending reactions', pendingReactions); - - // TODO: maybe this also must call resolve - await Promise.all( - pendingReactions.map((action) => - ctx.db.patch(action._id, { - status: 'skipped', - costs: [], - result: { - text: 'new human actions happened before this one could run', - reactions: [], - }, - }), - ), - ); - - if (shouldSkipRunning) { - await stopRunningAction(ctx, { taskId, author: owner, authorIsOwner: true }); - } - }, -}); - -// TODO: I think this should be splitted/abstracted -// one logic for auto-approval (i.e. from within action.perform()) -// another one for explicit human approval/rejection (i.e. from the UI) -// e.g. update the task status from here feels wrong -// instintic: if reject, should call resolve -// also maybe rename 'resolve' to something else because of task's -export const authorizeAction = defineMutation({ - args: z.object({ - taskId: zid('tasks'), - actionId: zid('actions'), - approver: z.union([ - zid('users'), // - z.literal('auto'), - ]), - hasApproved: z.boolean(), - }), - handler: async (ctx, { taskId, actionId, approver, hasApproved }) => { - // - const action = await findAction(ctx, { actionId }); - if (action.approvedAt) return; - - // if already running, keep running - else enqueue - const approvedStatus = action.status === 'running' ? ('running' as const) : ('enqueued' as const); - - // TODO: `running` here is confusing, but it really means `claimed`. We should likely have a new status for that. - console.debug(`${approver} ${hasApproved ? 'approved' : 'rejected'} ${action.skillKey} (${action._id})`); - - const patch = hasApproved - ? { - status: approvedStatus, - approvedBy: approver, - approvedAt: Date.now(), - } - : { - status: 'skipped' as const, - costs: [], - result: { - text: 'rejected by ' + approver, - reactions: [], - }, - }; - - // if rejected by user, go back to 'idle' (not 'unread' because it's an explicit user action) - if (!hasApproved) { - await setTaskStatus(ctx, { taskId, newStatus: 'idle' }); - } - - await ctx.db.patch(actionId, patch); - await runNextActionIfNeeded(ctx, { taskId }); - }, -}); - -export const addActions = defineMutation({ - args: z.object({ - taskId: zid('tasks'), - owner: zid('users'), - author: authorSchema, - skills: z.array( - z.object({ - skillKey: z.string(), - args: z.record(z.unknown()), - }), - ), - depth: z.number(), - shouldReopen: z.boolean().optional().default(false), - }), - handler: async (ctx, { taskId, owner, author, skills, depth, shouldReopen }) => { - // - const task = await ctx.db.get(taskId); - if (!task) throw NotFound(); - - // skip all pending reactions if adding human actions - if (author === owner) { - await skipAllPendingReactions(ctx, { taskId, owner, shouldSkipRunning: true }); - } - - // reopen if needed and requested - const skillsToSchedule = (() => { - // - const hasReopen = skills.some((skill) => skill.skillKey === 'reopen'); - - if (!task.isActive && shouldReopen && !hasReopen) { - return [{ skillKey: 'reopen', args: {} }].concat(skills); - } - - return skills; - })(); - - const actionIds = await Promise.all( - skillsToSchedule.map((skill) => - ctx.db.insert('actions', { - taskId, - author, - owner, - depth, - status: 'enqueued', - result: null, - skillKey: skill.skillKey, - args: skill.args, - }), - ), - ); - - await runNextActionIfNeeded(ctx, { taskId }); - - return actionIds; - }, -}); - -export const addAction = defineMutation({ - args: z.object({ - taskId: zid('tasks'), - owner: zid('users'), - author: authorSchema, - skillKey: z.string(), - args: z.record(z.unknown()), - depth: z.number(), - shouldReopen: z.boolean().optional().default(false), - }), - handler: async (ctx, { taskId, owner, author, skillKey, args, depth, shouldReopen }) => { - // - const actionIds = await addActions(ctx, { - taskId, - author, - owner, - depth, - shouldReopen, - skills: [{ skillKey, args }], - }); - - return actionIds[0]; - }, -}); - -export const findActionsSince = defineQuery({ - args: z.object({ - taskId: zid('tasks'), - since: z.number(), - }), - handler: async (ctx, { taskId, since }) => { - // - return await ctx.db - .query('actions') - .withIndex('by_task', (q) => - q - .eq('taskId', taskId) // - .gte('_creationTime', since), - ) - .collect(); - }, -}); - -export const findActionsPaginated = defineQuery({ - args: z.object({ - taskId: zid('tasks'), - paginationOpts: paginationOptionsSchema, - }), - handler: async (ctx, { taskId, paginationOpts }) => { - // - return await ctx.db - .query('actions') - .withIndex('by_task', (q) => q.eq('taskId', taskId)) - .order('desc') - .paginate(paginationOpts); - }, -}); - -export const findRunningActions = defineQuery({ - args: z.object({ - taskId: zid('tasks'), - }), - handler: async (ctx, { taskId }) => { - // - return await findByStatus(ctx, { taskId, status: 'running' }).collect(); - }, -}); - -export const findPendingAuthorizationAction = defineQuery({ - args: z.object({ - taskId: zid('tasks'), - }), - handler: async (ctx, { taskId }) => { - // - return await findByStatus(ctx, { taskId, status: 'pending authorization' }).first(); - }, -}); - -export const skipPendingAuthorizationByTaskAuthor = defineMutation({ - args: z.object({ - taskId: zid('tasks'), - author: authorSchema, - reasonText: z.string(), - }), - handler: async (ctx, { taskId, author, reasonText }) => { - // - const pendingActions = await ctx.db - .query('actions') - .withIndex('by_task_author_status', (q) => - q - .eq('taskId', taskId) // - .eq('author', author) - .eq('status', 'pending authorization'), - ) - .order('asc') - .collect(); - - return await Promise.all( - pendingActions.map((action) => - ctx.db.patch(action._id, { - status: 'skipped' as const, - costs: [], - result: { - text: reasonText, - reactions: [], - }, - }), - ), - ); - }, -}); - -export const findNextAction = defineQuery({ - args: z.object({ - taskId: zid('tasks'), - }), - handler: async (ctx, { taskId }) => { - // - return findByStatus(ctx, { taskId, status: 'enqueued' }).first(); - }, -}); - -export const findLastActions = defineQuery({ - args: z.object({ - taskId: zid('tasks'), - amount: z.number(), - }), - handler: async (ctx, { taskId, amount }) => { - // - return await ctx.db - .query('actions') - .withIndex('by_task', (q) => q.eq('taskId', taskId)) - .order('desc') - .take(amount); - }, -}); - -// helper, not exported — can be used with .first() or .collect() -const findByStatus = ( - ctx: QueryCtx | MutationCtx, - { taskId, status }: { taskId: Id<'tasks'>; status: z.infer }, -) => { - return ctx.db - .query('actions') - .withIndex('by_task_status', (q) => - q - .eq('taskId', taskId) // - .eq('status', status), - ) - .order('asc'); -}; diff --git a/apps/meseeks/convex/action.ts b/apps/meseeks/convex/action.ts deleted file mode 100644 index 07a60b76..00000000 --- a/apps/meseeks/convex/action.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { zid } from 'convex-helpers/server/zod3'; -import { z } from 'zod/v3'; -import { internalMutation, internalQuery, mutation, query } from 'lib/convex'; -import { paginationOptionsSchema } from 'schemas/paginationOptionsSchema'; -import { ensureTaskOwner } from './tasks.private'; -import { - addActions, - authorizeAction, - findActionsPaginated, - findRunningActions, - findLastActions, - findAction, -} from './action.private'; - -// used by reactor auto-approval -export const _authorize = internalMutation({ - args: { - taskId: zid('tasks'), - actionId: zid('actions'), - approver: z.union([ - zid('users'), // - z.literal('auto'), - ]), - hasApproved: z.boolean(), - }, - handler: authorizeAction, -}); - -// used by magicRock history rendering and by lifecycle's consecutive-companion guard -export const _findLastActions = internalQuery({ - args: { - taskId: zid('tasks'), - amount: z.number().min(1), - }, - handler: findLastActions, -}); - -export const act = mutation({ - args: { - taskId: zid('tasks'), - skills: z - .array( - z.object({ - skillKey: z.string(), - args: z.record(z.unknown()), - }), - ) - .min(1), - shouldReopen: z.boolean().optional().default(true), - }, - handler: async (ctx, { taskId, skills, shouldReopen }) => { - // - console.debug(`using ${skills.map((s) => s.skillKey).join(', ')} on task '${taskId}'`); - - const { currentUser } = await ensureTaskOwner(ctx, { taskId }); - - return await addActions(ctx, { - skills, - taskId, - depth: 0, - author: currentUser._id, - owner: currentUser._id, - shouldReopen, - }); - }, -}); - -export const authorize = mutation({ - args: { - taskId: zid('tasks'), - actionId: zid('actions'), - hasApproved: z.boolean(), - }, - handler: async (ctx, { taskId, actionId, hasApproved }) => { - // - const { currentUser } = await ensureTaskOwner(ctx, { taskId }); - - await authorizeAction(ctx, { - taskId, - actionId, - approver: currentUser._id, - hasApproved, - }); - }, -}); - -export const findAllPaginated = query({ - args: { - taskId: zid('tasks'), - paginationOpts: paginationOptionsSchema, - }, - handler: async (ctx, { taskId, paginationOpts }) => { - // - await ensureTaskOwner(ctx, { taskId }); - - return await findActionsPaginated(ctx, { taskId, paginationOpts }); - }, -}); - -export const findAllRunning = query({ - args: { - taskId: zid('tasks'), - }, - handler: async (ctx, { taskId }) => { - // - await ensureTaskOwner(ctx, { taskId }); - - return await findRunningActions(ctx, { taskId }); - }, -}); - -export const findOne = query({ - args: { - actionId: zid('actions'), - }, - handler: async (ctx, { actionId }) => { - // - const action = await findAction(ctx, { actionId }); - - await ensureTaskOwner(ctx, { taskId: action.taskId }); - - return action; - }, -}); diff --git a/apps/meseeks/convex/action/details.private.ts b/apps/meseeks/convex/action/details.private.ts index 6fad5d66..1e6a9932 100644 --- a/apps/meseeks/convex/action/details.private.ts +++ b/apps/meseeks/convex/action/details.private.ts @@ -1,56 +1,87 @@ import { zid } from 'convex-helpers/server/zod3'; import { z } from 'zod/v3'; import { defineMutation, defineQuery } from 'lib/convex'; -import { NotFound } from 'lib/errors'; -import { actionDetailSchema, actionDetailUpdateSchema } from 'schemas/actionDetailSchema'; +import { + actionDetailSchema, + preparationActionDetailSchema, + uploadActionDetailSchema, +} from 'schemas/actionDetailSchema'; export const findActionDetails = defineQuery({ args: z.object({ - actionId: zid('actions'), + action: zid('actions'), }), - handler: async (ctx, { actionId }) => { + handler: async (ctx, { action }) => { // return await ctx.db .query('action_details') - .withIndex('by_action', (q) => q.eq('actionId', actionId)) - .unique(); + .withIndex('by_action', (q) => q.eq('action', action)) + .collect(); }, }); -export const persistActionDetails = defineMutation({ +export const recordActionDetail = defineMutation({ args: z.object({ - details: actionDetailSchema, + detail: actionDetailSchema, }), - handler: async (ctx, { details }) => { + handler: async (ctx, { detail }) => { // - const existing = await findActionDetails(ctx, { actionId: details.actionId }); - if (existing) throw new Error('Action detail already exists'); - - return await ctx.db.insert('action_details', details); + return await ctx.db.insert('action_details', detail); }, }); -export const updateActionDetails = defineMutation({ +export const recordActionPreparation = defineMutation({ args: z.object({ - actionId: zid('actions'), - updates: actionDetailUpdateSchema, + detail: preparationActionDetailSchema, }), - handler: async (ctx, { actionId, updates }) => { + handler: async (ctx, { detail }) => { // - const existing = await findActionDetails(ctx, { actionId }); - if (!existing) throw NotFound(); + const details = await ctx.db + .query('action_details') + .withIndex('by_action', (q) => q.eq('action', detail.action)) + .collect(); + const existing = details.find((item) => item.kind === 'preparation'); - // Merge updates with existing data to maintain complete object structure - if ('llm' in updates && 'llm' in existing) { - const updatedLlm = { ...existing.llm, ...updates.llm }; - return await ctx.db.patch(existing._id, { llm: updatedLlm }); - } + if (existing) { + await ctx.db.patch(existing._id, detail); - if ('http' in updates && 'http' in existing) { - const updatedHttp = { ...existing.http, ...updates.http }; - return await ctx.db.patch(existing._id, { http: updatedHttp }); + return existing._id; } - throw new Error('Update type does not match existing document type'); + return await ctx.db.insert('action_details', detail); + }, +}); + +export const findActionPreparation = defineQuery({ + args: z.object({ + action: zid('actions'), + }), + handler: async (ctx, { action }) => { + // + const details = await ctx.db + .query('action_details') + .withIndex('by_action', (q) => q.eq('action', action)) + .collect(); + const detail = details.find((item) => item.kind === 'preparation'); + if (!detail) throw new Error('Action is not prepared.'); + + return preparationActionDetailSchema.parse(detail); + }, +}); + +export const findUploadTicket = defineQuery({ + args: z.object({ + action: zid('actions'), + }), + handler: async (ctx, { action }) => { + // + const details = await ctx.db + .query('action_details') + .withIndex('by_action', (q) => q.eq('action', action)) + .collect(); + const detail = details.find((item) => item.kind === 'upload'); + if (!detail) throw new Error('Upload ticket was not prepared.'); + + return uploadActionDetailSchema.parse(detail); }, }); diff --git a/apps/meseeks/convex/action/details.ts b/apps/meseeks/convex/action/details.ts index 4d06966e..d782e226 100644 --- a/apps/meseeks/convex/action/details.ts +++ b/apps/meseeks/convex/action/details.ts @@ -1,19 +1,13 @@ import { zid } from 'convex-helpers/server/zod3'; import { internalMutation, query } from 'lib/convex'; import { NotFound } from 'lib/errors'; -import { findActionDetails, persistActionDetails, updateActionDetails } from './details.private'; +import { findActionDetails, recordActionDetail } from './details.private'; import { getCurrentUser } from '../users.private'; -// used by reactor runtime to persist initial execution details before a skill runs -export const _persist = internalMutation({ - args: persistActionDetails.args.shape, - handler: persistActionDetails, -}); - -// used by createAITool/createHttpTool to append runtime metadata after tool execution -export const _update = internalMutation({ - args: updateActionDetails.args.shape, - handler: updateActionDetails, +// called by Reactor internals when an internal action needs to record post-call details. +export const _record = internalMutation({ + args: recordActionDetail.args.shape, + handler: recordActionDetail, }); export const find = query({ @@ -29,6 +23,6 @@ export const find = query({ if (!action) throw NotFound(); if (action.owner !== currentUser._id) throw NotFound(); - return await findActionDetails(ctx, { actionId }); + return await findActionDetails(ctx, { action: actionId }); }, }); diff --git a/apps/meseeks/convex/actions.private.ts b/apps/meseeks/convex/actions.private.ts new file mode 100644 index 00000000..571ff996 --- /dev/null +++ b/apps/meseeks/convex/actions.private.ts @@ -0,0 +1,62 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import type { Id } from 'convex/_generated/dataModel'; +import type { MutationCtx } from 'convex/_generated/server'; +import { defineMutation, defineQuery } from 'lib/convex'; +import { NotFound } from 'lib/errors'; +import { actionSkillSchema, actionSparkSchema } from 'schemas/actionSchema'; +import { authorSchema } from 'schemas/authorSchema'; +import { intelligenceKeys } from 'schemas/intelligenceSchema'; + +export const findAction = defineQuery({ + args: z.object({ + action: zid('actions'), + }), + handler: async (ctx, { action }) => { + // + const row = await ctx.db.get(action); + if (!row) throw NotFound(); + + return row; + }, +}); + +export const enqueueAction = defineMutation({ + args: z.object({ + owner: zid('users'), + author: authorSchema, + spark: actionSparkSchema, + root: zid('files'), + skill: actionSkillSchema, + intelligence: intelligenceKeys.optional(), + input: z.record(z.unknown()), + }), + handler: async (ctx, args) => { + // + const index = await nextIndex(ctx, { root: args.root }); + if (args.spark === 'self' && args.author !== args.owner) throw new Error('Reaction actions require spark.'); + + return await ctx.db.insert('actions', { + owner: args.owner, + root: args.root, + index, + author: args.author, + spark: args.spark, + skill: args.skill, + intelligence: args.intelligence, + input: args.input, + status: 'enqueued', + }); + }, +}); + +async function nextIndex(ctx: MutationCtx, { root }: { root: Id<'files'> }) { + // + const last = await ctx.db + .query('actions') + .withIndex('by_root_index', (q) => q.eq('root', root)) + .order('desc') + .first(); + + return (last?.index ?? 0) + 1; +} diff --git a/apps/meseeks/convex/actions.ts b/apps/meseeks/convex/actions.ts new file mode 100644 index 00000000..654dd1a5 --- /dev/null +++ b/apps/meseeks/convex/actions.ts @@ -0,0 +1,124 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import type { Id } from 'convex/_generated/dataModel'; +import { mutation, query } from 'lib/convex'; +import { NotFound } from 'lib/errors'; +import { intelligenceKeys } from 'schemas/intelligenceSchema'; +import { skillKeySchema } from 'schemas/skillSchema'; +import { getCurrentUser } from './users.private'; +import { enqueueAction, findAction } from './actions.private'; +import { ensureScopeOwner, ensureUserRootDirectory } from './files.private'; +import { findActionIntelligence, resolveCallableSkill, validateSkillInput } from './reactor/claim.private'; +import { claimNextAction } from './reactor.private'; + +export const act = mutation({ + args: { + actions: z + .array( + z.object({ + root: zid('files').optional(), + skill: skillKeySchema, + intelligence: intelligenceKeys.optional(), + input: z.record(z.unknown()).optional().default({}), + }), + ) + .min(1) + .max(25), + }, + handler: async (ctx, args) => { + // + const currentUser = await getCurrentUser(ctx, {}); + const enqueued: Array<{ + root: Id<'files'>; + skill: string; + intelligence?: (typeof args)['actions'][number]['intelligence']; + input: Record; + }> = []; + + for (const request of args.actions) { + const requestedRoot = request.root ?? (await ensureUserRootDirectory(ctx, { owner: currentUser._id })); + const root = ( + await ensureScopeOwner(ctx, { + owner: currentUser._id, + directory: requestedRoot, + }) + )._id; + + const skill = await resolveCallableSkill(ctx, { + owner: currentUser._id, + root, + key: request.skill, + }); + const input = validateSkillInput(skill, request.input); + const intelligence = findActionIntelligence(input, request.intelligence); + + enqueued.push({ + root, + skill: request.skill, + intelligence, + input, + }); + } + + const actions: Array> = []; + + for (const action of enqueued) { + actions.push( + await enqueueAction(ctx, { + owner: currentUser._id, + root: action.root, + author: currentUser._id, + spark: 'self', + skill: action.skill, + intelligence: action.intelligence, + input: action.input, + }), + ); + } + + const roots = new Set(enqueued.map((item) => item.root)); + for (const root of roots) { + await claimNextAction(ctx, { + owner: currentUser._id, + root, + }); + } + + return actions; + }, +}); + +export const find = query({ + args: { + action: zid('actions'), + }, + handler: async (ctx, args) => { + // + const currentUser = await getCurrentUser(ctx, {}); + const action = await findAction(ctx, args); + if (action.owner !== currentUser._id) throw NotFound(); + + return action; + }, +}); + +export const listByRoot = query({ + args: { + root: zid('files'), + limit: z.number().int().min(1).max(100).optional().default(50), + }, + handler: async (ctx, { root, limit }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + const actionRoot = await ensureScopeOwner(ctx, { + owner: currentUser._id, + directory: root, + }); + + return await ctx.db + .query('actions') + .withIndex('by_root_index', (q) => q.eq('root', actionRoot._id)) + .order('desc') + .take(limit); + }, +}); diff --git a/apps/meseeks/convex/babel.ts b/apps/meseeks/convex/babel.ts index 5e6a8296..d09eb033 100644 --- a/apps/meseeks/convex/babel.ts +++ b/apps/meseeks/convex/babel.ts @@ -4,7 +4,7 @@ import { transform } from '@babel/core'; import { z } from 'zod/v3'; import { internalAction } from 'lib/convex'; -// called by skills/builtIn/render.ts to transpile ai-generated jsx before sandbox iframe execution +// called by the renderer path to transpile ai-generated jsx before sandbox iframe execution export const _transpileCode = internalAction({ args: { code: z.string(), diff --git a/apps/meseeks/convex/betterAuth/component.ts b/apps/meseeks/convex/betterAuth/component.ts index e45a16cc..38de9abf 100644 --- a/apps/meseeks/convex/betterAuth/component.ts +++ b/apps/meseeks/convex/betterAuth/component.ts @@ -21,6 +21,7 @@ export const betterAuthComponent = createClient(co email: user.email, name: user.name ?? undefined, image: user.image ?? undefined, + isAnonymous: Boolean(user.isAnonymous), }); }, onUpdate: async (ctx, user) => { @@ -29,6 +30,7 @@ export const betterAuthComponent = createClient(co email: user.email, name: user.name ?? undefined, image: user.image ?? undefined, + isAnonymous: Boolean(user.isAnonymous), }); }, }, diff --git a/apps/meseeks/convex/compile.private.ts b/apps/meseeks/convex/compile.private.ts new file mode 100644 index 00000000..ee22a3ed --- /dev/null +++ b/apps/meseeks/convex/compile.private.ts @@ -0,0 +1,385 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import type { Doc } from './_generated/dataModel'; +import type { MutationCtx } from './_generated/server'; +import { preparationActionDetailSchema } from 'schemas/actionDetailSchema'; +import { + compiledSkillProjectionSchema, + compiledTriggerProjectionSchema, + type CompileMutation, + compileMutationSchema, +} from 'schemas/reactorSchema'; +import { fileRevisionChangeKindSchema } from 'schemas/fileRevisionSchema'; +import { configuredSkillKindSchema } from 'schemas/skillSchema'; +import { zodToString } from 'lib/zodToString'; +import { listRuntimeSourceFiles } from './files.private'; +import { readText } from './storage.private'; + +export const compilePreparationSchema = z.object({ + sources: z.array( + z.object({ + file: zid('files'), + path: z.string().min(1), + hash: z.string().optional(), + storageKey: z.string().optional(), + contentType: z.string().optional(), + }), + ), +}); + +const genericInputSchema = zodToString(z.record(z.unknown())); +const textOutputSchema = zodToString( + z.object({ + summary: z.string().optional(), + content: z.string().optional(), + contentType: z.string().optional(), + }), +); + +export async function prepareCompile(ctx: MutationCtx, { action }: { action: Doc<'actions'> }) { + // + const sources = await listRuntimeSourceFiles(ctx, { + owner: action.owner, + root: action.root, + }); + const now = Date.now(); + + return preparationActionDetailSchema.parse({ + action: action._id, + owner: action.owner, + createdAt: now, + kind: 'preparation', + skill: action.skill, + skillKind: 'instinct', + preparedAt: now, + context: { sources }, + }); +} + +export async function compilePreparedSources(preparation: unknown): Promise<{ + mutation: CompileMutation; + content: string; + warnings: Array; +}> { + // + const { sources } = compilePreparationSchema.parse(preparation); + const mutation: CompileMutation = { + kind: 'compile', + skills: [], + pages: [], + triggers: [], + diagnostics: [], + }; + const seenSkills = new Set(); + const seenPages = new Set(); + + for (const source of sources) { + if (!source.storageKey) { + mutation.diagnostics.push(`${source.path}: missing Object Storage body.`); + continue; + } + + const content = await readText({ storageKey: source.storageKey }); + if (isPageSource(source.path)) { + const route = routeFromPagePath(source.path); + if (seenPages.has(route)) { + mutation.diagnostics.push(`${source.path}: duplicate page route ${route}.`); + continue; + } + seenPages.add(route); + mutation.pages.push({ + file: source.file, + route, + sourcePath: source.path, + sourceHash: source.hash, + }); + continue; + } + + if (isSkillSource(source.path)) { + const skill = catchDiagnostic(source.path, mutation.diagnostics, () => + compileSkillSource({ + file: source.file, + path: source.path, + hash: source.hash, + content, + diagnostics: mutation.diagnostics, + }), + ); + if (!skill) continue; + if (seenSkills.has(skill.key)) { + mutation.diagnostics.push(`${source.path}: duplicate skill key ${skill.key}.`); + continue; + } + seenSkills.add(skill.key); + mutation.skills.push(skill); + continue; + } + + if (isTriggerSource(source.path)) { + const triggers = catchDiagnostic(source.path, mutation.diagnostics, () => + compileTriggerSource({ + file: source.file, + path: source.path, + hash: source.hash, + content, + diagnostics: mutation.diagnostics, + }), + ); + if (triggers) mutation.triggers.push(...triggers); + } + } + + const parsed = compileMutationSchema.parse(mutation); + + return { + mutation: parsed, + content: compileSummary(parsed), + warnings: parsed.diagnostics, + }; +} + +function compileSkillSource({ + file, + path, + hash, + content, + diagnostics, +}: { + file: Doc<'files'>['_id']; + path: string; + hash?: string; + content: string; + diagnostics: Array; +}) { + // + const kind = configuredSkillKindSchema.safeParse(stringProperty(content, 'kind')); + if (!kind.success) { + diagnostics.push(`${path}: skill kind must be one of think, request, or execute.`); + return undefined; + } + + const key = skillKeyFromPath(path); + const description = stringProperty(content, 'description') ?? key; + const config = compileSkillConfig({ content, description, kind: kind.data, path, diagnostics }); + if (!config) return undefined; + + return compiledSkillProjectionSchema.parse({ + key, + description, + kind: kind.data, + inputSchema: genericInputSchema, + outputSchema: textOutputSchema, + config, + sourceFile: file, + sourcePath: path, + sourceHash: hash, + }); +} + +function compileSkillConfig({ + content, + description, + kind, + path, + diagnostics, +}: { + content: string; + description: string; + kind: 'think' | 'request' | 'execute'; + path: string; + diagnostics: Array; +}) { + // + if (kind === 'think') { + return { + model: stringProperty(content, 'model') ?? 'auto', + instructions: stringProperty(content, 'instructions') ?? description, + temperature: numberProperty(content, 'temperature') ?? 0.7, + availableSkills: stringArrayProperty(content, 'availableSkills') ?? [], + historyMode: 'since last instructed', + }; + } + + if (kind === 'request') { + const url = stringProperty(content, 'url'); + if (!url) { + diagnostics.push(`${path}: request skills require a url string.`); + return undefined; + } + + return { + url, + method: stringProperty(content, 'method') ?? 'GET', + paramMappings: [], + }; + } + + return { + language: stringProperty(content, 'language') ?? 'javascript', + timeoutSeconds: numberProperty(content, 'timeoutSeconds'), + }; +} + +function compileTriggerSource({ + file, + path, + hash, + content, + diagnostics, +}: { + file: Doc<'files'>['_id']; + path: string; + hash?: string; + content: string; + diagnostics: Array; +}) { + // + const skill = stringProperty(content, 'skill'); + if (!skill) { + diagnostics.push(`${path}: mutation trigger requires a reaction skill string.`); + return []; + } + + return triggerPatterns(content).map((pattern) => + compiledTriggerProjectionSchema.parse({ + kind: 'mutation' as const, + events: mutationEvents(content), + pattern, + reactions: [ + { + skill, + input: reactionInput(content, skill), + }, + ], + maxUses: maxUses(content), + sourceFile: file, + sourcePath: path, + sourceHash: hash, + }), + ); +} + +function mutationEvents(content: string) { + // + const events = stringArrayProperty(content, 'events'); + if (events.length > 0) return z.array(fileRevisionChangeKindSchema).parse(events); + + return ['create', 'update']; +} + +function maxUses(content: string) { + // + if (/maxUses\s*:\s*Infinity/.test(content)) return undefined; + + return numberProperty(content, 'maxUses'); +} + +function triggerPatterns(content: string) { + // + const patterns = stringArrayProperty(content, 'patterns'); + if (patterns.length > 0) return patterns; + + return [stringProperty(content, 'pattern')]; +} + +function reactionInput(content: string, skill: string) { + // + if (skill === 'compile') return {}; + + return { + message: stringProperty(content, 'message') ?? `Triggered ${skill}.`, + }; +} + +function catchDiagnostic(path: string, diagnostics: Array, fn: () => T) { + // + try { + return fn(); + } catch (error) { + diagnostics.push(`${path}: ${errorMessage(error)}`); + return undefined; + } +} + +function errorMessage(error: unknown) { + // + if (error instanceof Error) return error.message; + + return String(error); +} + +function stringProperty(source: string, key: string) { + // + const match = new RegExp(`${key}\\s*:\\s*(['"\`])([\\s\\S]*?)\\1`).exec(source); + if (!match) return undefined; + + return match[2]; +} + +function numberProperty(source: string, key: string) { + // + const match = new RegExp(`${key}\\s*:\\s*(\\d+(?:\\.\\d+)?)`).exec(source); + if (!match) return undefined; + + return Number(match[1]); +} + +function stringArrayProperty(source: string, key: string) { + // + const match = new RegExp(`${key}\\s*:\\s*\\[([^\\]]*)\\]`).exec(source); + if (!match) return []; + + return Array.from(match[1].matchAll(/['"]([^'"]+)['"]/g)).map((item) => item[1]); +} + +function isSkillSource(path: string) { + // + return path.startsWith('/.pro/skills/') && path.endsWith('.ts'); +} + +function isTriggerSource(path: string) { + // + return path.startsWith('/.pro/triggers/') && path.endsWith('.ts'); +} + +function isPageSource(path: string) { + // + if (path.includes('/.pro/')) return false; + + return path === '/page.tsx' || path.endsWith('/page.tsx'); +} + +function skillKeyFromPath(path: string) { + // + const key = path.slice('/.pro/skills/'.length).replace(/\.ts$/, ''); + if (key.endsWith('/index')) return key.slice(0, -'/index'.length); + + return key; +} + +function routeFromPagePath(path: string) { + // + const route = path.replace(/\/page\.tsx$/, ''); + if (!route) return '/'; + + return route; +} + +function compileSummary(mutation: CompileMutation) { + // + const lines = [ + '# Compile', + '', + `- skills: ${mutation.skills.length}`, + `- triggers: ${mutation.triggers.length}`, + `- pages: ${mutation.pages.length}`, + `- diagnostics: ${mutation.diagnostics.length}`, + ]; + + if (mutation.diagnostics.length > 0) { + lines.push('', '## Diagnostics', '', ...mutation.diagnostics.map((diagnostic) => `- ${diagnostic}`)); + } + + return lines.join('\n'); +} diff --git a/apps/meseeks/convex/components.private.ts b/apps/meseeks/convex/components.private.ts deleted file mode 100644 index 952fc819..00000000 --- a/apps/meseeks/convex/components.private.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { zid } from 'convex-helpers/server/zod3'; -import { z } from 'zod/v3'; -import { defineMutation, defineQuery } from 'lib/convex'; -import { NotFound } from 'lib/errors'; -import { componentSchema } from 'schemas/componentSchema'; - -export const addComponent = defineMutation({ - args: componentSchema, - handler: async (ctx, component) => { - // - return await ctx.db.insert('components', component); - }, -}); - -export const findComponent = defineQuery({ - args: z.object({ - componentId: zid('components'), - }), - handler: async (ctx, { componentId }) => { - // - const component = await ctx.db.get(componentId); - if (!component) throw NotFound(); - - return component; - }, -}); - -export const findAllComponents = defineQuery({ - args: z.object({ - userId: zid('users'), - }), - handler: async (ctx, { userId }) => { - // - return await ctx.db - .query('components') - .withIndex('by_owner_slug', (q) => q.eq('owner', userId)) - .collect(); - }, -}); - -export const findComponentBySlug = defineQuery({ - args: z.object({ - slug: z.string(), - userId: zid('users'), - }), - handler: async (ctx, { slug, userId }) => { - // - const users = await ctx.db - .query('components') - .withIndex('by_owner_slug', (q) => q.eq('owner', userId).eq('slug', slug)) - .unique(); - - if (users) return users; - - const globals = await ctx.db - .query('components') - .withIndex('by_owner_slug', (q) => q.eq('owner', 'isPro').eq('slug', slug)) - .unique(); - - return globals; - }, -}); - -export const shareComponentPublicly = defineMutation({ - args: z.object({ - owner: zid('users'), - body: z.string().min(1), - }), - handler: async (ctx, { owner, body }) => { - // - const componentId = await addComponent(ctx, { - isPublic: true, - owner, - body, - }); - - return { componentId }; - }, -}); diff --git a/apps/meseeks/convex/components.ts b/apps/meseeks/convex/components.ts deleted file mode 100644 index 797c666d..00000000 --- a/apps/meseeks/convex/components.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { zid } from 'convex-helpers/server/zod3'; -import { z } from 'zod/v3'; -import { mutation, query } from 'lib/convex'; -import { NotFound } from 'lib/errors'; -import { findAction } from './action.private'; -import { findComponent, findComponentBySlug, shareComponentPublicly } from './components.private'; -import { ensureTaskOwner } from './tasks.private'; -import { getCurrentUser } from './users.private'; - -export const findOneBySlug = query({ - args: { - slug: z.string(), - }, - handler: async (ctx, { slug }) => { - // - const currentUser = await getCurrentUser(ctx, {}); - const page = await findComponentBySlug(ctx, { slug, userId: currentUser._id }); - - if (page) return page; - - throw NotFound(); - }, -}); - -export const shareRenderAction = mutation({ - args: { - actionId: zid('actions'), - }, - handler: async (ctx, { actionId }) => { - // - const action = await findAction(ctx, { actionId }); - const { currentUser } = await ensureTaskOwner(ctx, { taskId: action.taskId }); - - const body = action.result?.text; - const hasBody = typeof body === 'string' && body.length > 0; - - if (!hasBody) throw NotFound(); - if (action.skillKey !== 'render') throw NotFound(); - if (action.status !== 'succeeded') throw NotFound(); - - return await shareComponentPublicly(ctx, { - owner: currentUser._id, - body, - }); - }, -}); - -export const findPublicById = query({ - args: { - componentId: zid('components'), - }, - handler: async (ctx, { componentId }) => { - // - const component = await findComponent(ctx, { componentId }); - - if (!component.isPublic) throw NotFound(); - if (component.slug) throw NotFound(); - - return { - _id: component._id, - body: component.body, - }; - }, -}); diff --git a/apps/meseeks/convex/drafts.private.ts b/apps/meseeks/convex/drafts.private.ts deleted file mode 100644 index dfcff100..00000000 --- a/apps/meseeks/convex/drafts.private.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { zid } from 'convex-helpers/server/zod3'; -import { z } from 'zod/v3'; -import { defineMutation, defineQuery } from 'lib/convex'; -import { draftQueueItemSchema } from 'schemas/draftSchema'; - -export const findDraft = defineQuery({ - args: z.object({ - owner: zid('users'), - taskId: zid('tasks'), - }), - handler: async (ctx, { owner, taskId }) => { - // - return await ctx.db - .query('drafts') - .withIndex('by_owner_taskId', (q) => q.eq('owner', owner).eq('taskId', taskId)) - .unique(); - }, -}); - -// export const _findAll: ReturnType = internalQuery({ -// args: { -// owner: zid('users'), -// }, -// handler: async (ctx, { owner }) => { -// // -// return await ctx.db -// .query('drafts') -// .withIndex('by_owner_taskId', (q) => q.eq('owner', owner)) -// .order('desc') -// .collect(); -// }, -// }); - -export const saveDraft = defineMutation({ - args: z.object({ - owner: zid('users'), - taskId: zid('tasks'), - queue: z.array(draftQueueItemSchema), - message: z.string(), - }), - handler: async (ctx, { owner, taskId, queue, message }) => { - // - const existing = await findDraft(ctx, { owner, taskId }); - const data = { owner, taskId, queue, message, updatedAt: Date.now() }; - - if (existing) { - await ctx.db.patch(existing._id, data); - } else { - await ctx.db.insert('drafts', data); - } - }, -}); - -export const clearDraft = defineMutation({ - args: z.object({ - owner: zid('users'), - taskId: zid('tasks'), - }), - handler: async (ctx, { owner, taskId }) => { - // - const existing = await findDraft(ctx, { owner, taskId }); - - if (existing) await ctx.db.delete(existing._id); - }, -}); diff --git a/apps/meseeks/convex/drafts.ts b/apps/meseeks/convex/drafts.ts deleted file mode 100644 index a707a720..00000000 --- a/apps/meseeks/convex/drafts.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { zid } from 'convex-helpers/server/zod3'; -import { z } from 'zod/v3'; -import { mutation, query } from 'lib/convex'; -import { clearDraft, findDraft, saveDraft } from './drafts.private'; -import { draftQueueItemSchema } from 'schemas/draftSchema'; -import { getCurrentUser } from './users.private'; - -export const findOne = query({ - args: { - taskId: zid('tasks'), - }, - handler: async (ctx, { taskId }) => { - // - const currentUser = await getCurrentUser(ctx, {}); - return await findDraft(ctx, { owner: currentUser._id, taskId }); - }, -}); - -export const save = mutation({ - args: { - taskId: zid('tasks'), - queue: z.array(draftQueueItemSchema), - message: z.string(), - }, - handler: async (ctx, { taskId, queue, message }) => { - // - const currentUser = await getCurrentUser(ctx, {}); - return await saveDraft(ctx, { owner: currentUser._id, taskId, queue, message }); - }, -}); - -export const clear = mutation({ - args: { - taskId: zid('tasks'), - }, - handler: async (ctx, { taskId }) => { - // - const currentUser = await getCurrentUser(ctx, {}); - await clearDraft(ctx, { owner: currentUser._id, taskId }); - }, -}); diff --git a/apps/meseeks/convex/execute.private.ts b/apps/meseeks/convex/execute.private.ts new file mode 100644 index 00000000..ec29bc55 --- /dev/null +++ b/apps/meseeks/convex/execute.private.ts @@ -0,0 +1,49 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import type { Id } from 'convex/_generated/dataModel'; +import type { StagedText } from 'schemas/reactorSchema'; + +export const executePreparationSchema = z.object({ + root: zid('files'), + code: z.string().min(1).max(10_000), + language: z + .enum([ + 'javascript', // + 'python', + ]) + .default('javascript'), + timeoutSeconds: z.number().int().positive().optional(), + warnings: z.array(z.string()).optional(), +}); + +export type ExecutePreparation = z.infer; + +export function prepareExecute(args: z.input) { + // + return executePreparationSchema.parse(args); +} + +export async function performExecute({ + action, + stageText, + warnings, +}: { + action: { _id: Id<'actions'>; owner: Id<'users'> }; + preparation: ExecutePreparation; + stageText(args: { owner: Id<'users'>; content: string; contentType: string }): Promise; + warnings: Array; +}) { + // + const output = await stageText({ + owner: action.owner, + content: 'Execute is not wired to boxes in the current runtime.', + contentType: 'text/mdx; charset=utf-8', + }); + + return { + action: action._id, + status: 'skipped' as const, + output, + warnings: warnings.concat('Execute is not wired to boxes in the current runtime.'), + }; +} diff --git a/apps/meseeks/convex/files.private.ts b/apps/meseeks/convex/files.private.ts new file mode 100644 index 00000000..3fadc022 --- /dev/null +++ b/apps/meseeks/convex/files.private.ts @@ -0,0 +1,897 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import type { Doc, Id } from 'convex/_generated/dataModel'; +import type { MutationCtx, QueryCtx } from 'convex/_generated/server'; +import { internal } from 'convex/_generated/api'; +import { defineMutation, defineQuery } from 'lib/convex'; +import { NotFound } from 'lib/errors'; +import { enqueueAction } from './actions.private'; +import { ensureInstinctSkillRows } from './skills.private'; + +export const findFile = defineQuery({ + args: z.object({ + file: zid('files'), + }), + handler: async (ctx, { file }) => { + // + const row = await ctx.db.get(file); + if (!row) throw NotFound(); + + return row; + }, +}); + +export const ensureFileOwner = defineQuery({ + args: z.object({ + owner: zid('users'), + file: zid('files'), + }), + handler: async (ctx, { owner, file }) => { + // + const row = await findFile(ctx, { file }); + if (row.owner !== owner) throw NotFound(); + + return row; + }, +}); + +export const ensureDirectoryOwner = defineQuery({ + args: z.object({ + owner: zid('users'), + directory: zid('files'), + }), + handler: async (ctx, { owner, directory }) => { + // + const row = await findFile(ctx, { file: directory }); + if (row.owner !== owner) throw NotFound(); + if (row.kind !== 'directory') throw NotFound(); + + return row; + }, +}); + +export const ensureScopeOwner = defineQuery({ + args: z.object({ + owner: zid('users'), + directory: zid('files'), + }), + handler: async (ctx, { owner, directory }) => { + // + let current = await ensureDirectoryOwner(ctx, { owner, directory }); + const ancestors: Array> = []; + + while (true) { + ancestors.push(current); + + if (current.parent === 'root') break; + + current = await ensureDirectoryOwner(ctx, { owner, directory: current.parent }); + } + + for (const ancestor of ancestors) { + if (ancestor.name !== '.pro') continue; + if (ancestor.parent === 'root') throw NotFound(); + + return await ensureDirectoryOwner(ctx, { owner, directory: ancestor.parent }); + } + + for (const ancestor of ancestors) { + const control = await findControlDirectory(ctx, { owner, directory: ancestor._id }); + if (control) return ancestor; + } + + const fallback = ancestors[ancestors.length - 1]; + if (!fallback) throw NotFound(); + + return fallback; + }, +}); + +export const ensureUserRootDirectory = defineMutation({ + args: z.object({ + owner: zid('users'), + }), + handler: async (ctx, { owner }) => { + // + await ensureInstinctSkillRows(ctx, {}); + + const user = await ctx.db.get(owner); + if (!user) throw NotFound(); + if (user.root) return user.root; + + return await createUserRootDirectory(ctx, { owner }); + }, +}); + +export const listFileChildren = defineQuery({ + args: z.object({ + owner: zid('users'), + parent: zid('files'), + }), + handler: async (ctx, { owner, parent }) => { + // + await ensureDirectoryOwner(ctx, { owner, directory: parent }); + + return await ctx.db + .query('files') + .withIndex('by_owner_parent_name', (q) => + q + .eq('owner', owner) // + .eq('parent', parent), + ) + .collect(); + }, +}); + +export async function listRuntimeSourceFiles( + ctx: QueryCtx | MutationCtx, + { owner, root, limit = 300 }: { owner: Id<'users'>; root: Id<'files'>; limit?: number }, +) { + // + await ensureDirectoryOwner(ctx, { owner, directory: root }); + + const queue: Array<{ directory: Id<'files'>; path: string }> = [{ directory: root, path: '/' }]; + const sources: Array<{ + file: Id<'files'>; + path: string; + hash?: string; + storageKey?: string; + contentType?: string; + }> = []; + + while (queue.length > 0) { + const item = queue.shift(); + if (!item) break; + + const children = await ctx.db + .query('files') + .withIndex('by_owner_parent_name', (q) => + q + .eq('owner', owner) // + .eq('parent', item.directory), + ) + .collect(); + + for (const child of children) { + const path = childPath(item.path, child.name); + if (child.kind === 'directory') { + const isActionOutputDirectory = path === '/.pro/actions'; + const isChildScope = + child.name !== '.pro' && Boolean(await findControlDirectory(ctx, { owner, directory: child._id })); + + if (!isActionOutputDirectory && !isChildScope) { + queue.push({ directory: child._id, path }); + } + continue; + } + + if (!isRuntimeSourcePath(path)) continue; + if (sources.length >= limit) throw new Error('Too many runtime source files.'); + + sources.push({ + file: child._id, + path, + hash: child.hash, + storageKey: await findCurrentStorageKey(ctx, { file: child }), + contentType: child.contentType, + }); + } + } + + return sources; +} + +export const getFileWriteContext = defineQuery({ + args: z.object({ + owner: zid('users'), + file: zid('files'), + expectedRevision: zid('file_revisions').optional(), + }), + handler: async (ctx, { owner, file, expectedRevision }) => { + // + const row = await ensureFileOwner(ctx, { owner, file }); + if (row.kind !== 'file') throw NotFound(); + if (expectedRevision && row.currentRevision !== expectedRevision) throw new Error('File changed before write.'); + + return { + contentType: row.contentType, + currentRevision: row.currentRevision, + hash: row.hash, + path: await buildPath(ctx, { file }), + size: row.size, + storageKey: await findCurrentStorageKey(ctx, { file: row }), + }; + }, +}); + +export async function createDirectoryForAction( + ctx: MutationCtx, + args: { + owner: Id<'users'>; + action: Id<'actions'>; + parent: Id<'files'>; + name: string; + }, +) { + // + await ensureDirectoryOwner(ctx, { + owner: args.owner, + directory: args.parent, + }); + + await ensureChildNameAvailable(ctx, { + owner: args.owner, + parent: args.parent, + name: args.name, + }); + + const file = await ctx.db.insert('files', { + owner: args.owner, + parent: args.parent, + name: args.name, + kind: 'directory', + author: args.action, + }); + + const afterPath = await buildPath(ctx, { file }); + const revision = await ctx.db.insert('file_revisions', { + owner: args.owner, + file, + action: args.action, + changeKind: 'create', + afterPath, + patch: JSON.stringify({ + kind: 'create', + after: { + path: afterPath, + kind: 'directory', + }, + }), + }); + + await ctx.db.patch(file, { currentRevision: revision }); + + return await findFile(ctx, { file }); +} + +export async function createTextFileForAction( + ctx: MutationCtx, + args: { + owner: Id<'users'>; + action: Id<'actions'>; + parent: Id<'files'>; + name: string; + content: string; + contentType: string; + hash: string; + size: number; + storageKey: string; + }, +) { + // + await ensureDirectoryOwner(ctx, { + owner: args.owner, + directory: args.parent, + }); + + await ensureChildNameAvailable(ctx, { + owner: args.owner, + parent: args.parent, + name: args.name, + }); + + const file = await ctx.db.insert('files', { + owner: args.owner, + parent: args.parent, + name: args.name, + kind: 'file', + contentType: args.contentType, + size: args.size, + hash: args.hash, + author: args.action, + }); + + const afterPath = await buildPath(ctx, { file }); + const revision = await ctx.db.insert('file_revisions', { + owner: args.owner, + file, + action: args.action, + changeKind: 'create', + afterPath, + afterHash: args.hash, + afterSize: args.size, + storageKey: args.storageKey, + contentType: args.contentType, + patch: JSON.stringify({ + kind: 'create', + after: { + content: args.content, + contentType: args.contentType, + hash: args.hash, + path: afterPath, + size: args.size, + }, + }), + }); + + await ctx.db.patch(file, { currentRevision: revision }); + + return await findFile(ctx, { file }); +} + +export async function createStoredFileForAction( + ctx: MutationCtx, + args: { + owner: Id<'users'>; + action: Id<'actions'>; + parent: Id<'files'>; + name: string; + contentType: string; + hash: string; + size: number; + storageKey: string; + }, +) { + // + await ensureDirectoryOwner(ctx, { + owner: args.owner, + directory: args.parent, + }); + + await ensureChildNameAvailable(ctx, { + owner: args.owner, + parent: args.parent, + name: args.name, + }); + + const file = await ctx.db.insert('files', { + owner: args.owner, + parent: args.parent, + name: args.name, + kind: 'file', + contentType: args.contentType, + size: args.size, + hash: args.hash, + author: args.action, + }); + + const afterPath = await buildPath(ctx, { file }); + const revision = await ctx.db.insert('file_revisions', { + owner: args.owner, + file, + action: args.action, + changeKind: 'create', + afterPath, + afterHash: args.hash, + afterSize: args.size, + storageKey: args.storageKey, + contentType: args.contentType, + patch: JSON.stringify({ + kind: 'create', + after: { + contentType: args.contentType, + hash: args.hash, + path: afterPath, + size: args.size, + storageKey: args.storageKey, + }, + }), + }); + + await ctx.db.patch(file, { currentRevision: revision }); + + return await findFile(ctx, { file }); +} + +export async function createTextFileAtPathForAction( + ctx: MutationCtx, + args: { + owner: Id<'users'>; + action: Id<'actions'>; + parent: Id<'files'>; + path: Array; + content: string; + contentType: string; + hash: string; + size: number; + storageKey: string; + }, +) { + // + const revisions: Array> = []; + let parent = args.parent; + const directoryNames = args.path.slice(0, -1); + const name = args.path[args.path.length - 1]; + if (!name) throw new Error('Path must include a file name.'); + + for (const directoryName of directoryNames) { + const directory = await ensureDirectoryPathForAction(ctx, { + owner: args.owner, + action: args.action, + parent, + name: directoryName, + revisions, + }); + parent = directory._id; + } + + const file = await createTextFileForAction(ctx, { + owner: args.owner, + action: args.action, + parent, + name, + content: args.content, + contentType: args.contentType, + hash: args.hash, + size: args.size, + storageKey: args.storageKey, + }); + if (file.currentRevision) revisions.push(file.currentRevision); + + return { file, revisions }; +} + +export async function writeTextFileForAction( + ctx: MutationCtx, + args: { + owner: Id<'users'>; + action: Id<'actions'>; + file: Id<'files'>; + beforeContent: string; + content: string; + contentType: string; + hash: string; + size: number; + storageKey: string; + expectedRevision?: Id<'file_revisions'>; + }, +) { + // + const file = await ensureFileOwner(ctx, { + owner: args.owner, + file: args.file, + }); + if (file.kind !== 'file') throw NotFound(); + if (args.expectedRevision && file.currentRevision !== args.expectedRevision) + throw new Error('File changed before write.'); + + const path = await buildPath(ctx, { file: args.file }); + const revision = await ctx.db.insert('file_revisions', { + owner: args.owner, + file: args.file, + action: args.action, + previousRevision: file.currentRevision, + changeKind: 'update', + beforePath: path, + afterPath: path, + beforeHash: file.hash, + afterHash: args.hash, + beforeSize: file.size, + afterSize: args.size, + storageKey: args.storageKey, + contentType: args.contentType, + patch: JSON.stringify({ + kind: 'update', + before: { + content: args.beforeContent, + contentType: file.contentType, + hash: file.hash, + size: file.size, + }, + after: { + content: args.content, + contentType: args.contentType, + hash: args.hash, + size: args.size, + }, + }), + }); + + await ctx.db.patch(args.file, { + currentRevision: revision, + contentType: args.contentType, + size: args.size, + hash: args.hash, + }); + + return revision; +} + +export async function moveFileForAction( + ctx: MutationCtx, + args: { + owner: Id<'users'>; + action: Id<'actions'>; + file: Id<'files'>; + parent?: Id<'files'>; + name?: string; + }, +) { + // + const file = await ensureFileOwner(ctx, { + owner: args.owner, + file: args.file, + }); + + const parent = args.parent ?? file.parent; + const name = args.name ?? file.name; + if (parent === 'root') throw NotFound(); + if (parent === file.parent && name === file.name) return undefined; + + await ensureDirectoryOwner(ctx, { + owner: args.owner, + directory: parent, + }); + + await ensureChildNameAvailable(ctx, { + owner: args.owner, + parent, + name, + }); + + const beforePath = await buildPath(ctx, { file: args.file }); + + await ctx.db.patch(args.file, { + parent, + name, + }); + + const afterPath = await buildPath(ctx, { file: args.file }); + const revision = await ctx.db.insert('file_revisions', { + owner: args.owner, + file: args.file, + action: args.action, + previousRevision: file.currentRevision, + changeKind: 'rename', + beforePath, + afterPath, + beforeHash: file.hash, + afterHash: file.hash, + beforeSize: file.size, + afterSize: file.size, + contentType: file.contentType, + patch: JSON.stringify({ + kind: 'rename', + before: { path: beforePath }, + after: { path: afterPath }, + }), + }); + + await ctx.db.patch(args.file, { currentRevision: revision }); + + return revision; +} + +export async function tagFileForAction( + ctx: MutationCtx, + args: { + owner: Id<'users'>; + action: Id<'actions'>; + file: Id<'files'>; + key: string; + value?: string; + }, +) { + // + const file = await ensureFileOwner(ctx, { + owner: args.owner, + file: args.file, + }); + + const existing = await ctx.db + .query('file_tags') + .withIndex('by_file_key', (q) => + q + .eq('file', args.file) // + .eq('key', args.key), + ) + .unique(); + + if (existing) { + await ctx.db.patch(existing._id, { value: args.value }); + } else { + await ctx.db.insert('file_tags', { + owner: args.owner, + file: args.file, + key: args.key, + value: args.value, + }); + } + + const path = await buildPath(ctx, { file: args.file }); + const revision = await ctx.db.insert('file_revisions', { + owner: args.owner, + file: args.file, + action: args.action, + previousRevision: file.currentRevision, + changeKind: 'tag', + beforePath: path, + afterPath: path, + beforeHash: file.hash, + afterHash: file.hash, + beforeSize: file.size, + afterSize: file.size, + contentType: file.contentType, + patch: JSON.stringify({ + kind: 'tag', + key: args.key, + before: existing?.value, + after: args.value, + }), + }); + + await ctx.db.patch(args.file, { currentRevision: revision }); + + return revision; +} + +export async function untagFileForAction( + ctx: MutationCtx, + args: { + owner: Id<'users'>; + action: Id<'actions'>; + file: Id<'files'>; + key: string; + }, +) { + // + const file = await ensureFileOwner(ctx, { + owner: args.owner, + file: args.file, + }); + + const existing = await ctx.db + .query('file_tags') + .withIndex('by_file_key', (q) => + q + .eq('file', args.file) // + .eq('key', args.key), + ) + .unique(); + + if (!existing) return undefined; + + await ctx.db.delete(existing._id); + + const path = await buildPath(ctx, { file: args.file }); + const revision = await ctx.db.insert('file_revisions', { + owner: args.owner, + file: args.file, + action: args.action, + previousRevision: file.currentRevision, + changeKind: 'tag', + beforePath: path, + afterPath: path, + beforeHash: file.hash, + afterHash: file.hash, + beforeSize: file.size, + afterSize: file.size, + contentType: file.contentType, + patch: JSON.stringify({ + kind: 'tag', + key: args.key, + before: existing.value, + after: undefined, + }), + }); + + await ctx.db.patch(args.file, { currentRevision: revision }); + + return revision; +} + +export async function writeActionOutputFile( + ctx: MutationCtx, + args: { + owner: Id<'users'>; + action: Id<'actions'>; + root: Id<'files'>; + index: number; + content: string; + contentType: string; + hash: string; + size: number; + storageKey: string; + }, +) { + // + const pro = await ensureDirectoryPathForAction(ctx, { + owner: args.owner, + action: args.action, + parent: args.root, + name: '.pro', + }); + + const actions = await ensureDirectoryPathForAction(ctx, { + owner: args.owner, + action: args.action, + parent: pro._id, + name: 'actions', + }); + + return await createTextFileForAction(ctx, { + owner: args.owner, + action: args.action, + parent: actions._id, + name: `${String(args.index).padStart(6, '0')}-result.mdx`, + content: args.content, + contentType: args.contentType, + hash: args.hash, + size: args.size, + storageKey: args.storageKey, + }); +} + +export async function buildPath(ctx: QueryCtx | MutationCtx, { file }: { file: Id<'files'> }) { + // + const names: Array = []; + let current: Doc<'files'> | null = await ctx.db.get(file); + if (!current) throw NotFound(); + + while (current) { + if (current.name) names.unshift(current.name); + if (current.parent === 'root') break; + current = await ctx.db.get(current.parent); + } + + return '/' + names.join('/'); +} + +async function ensureDirectoryPathForAction( + ctx: MutationCtx, + args: { + owner: Id<'users'>; + action: Id<'actions'>; + parent: Id<'files'>; + name: string; + revisions?: Array>; + }, +) { + // + const existing = await ctx.db + .query('files') + .withIndex('by_owner_parent_name', (q) => + q + .eq('owner', args.owner) // + .eq('parent', args.parent) + .eq('name', args.name), + ) + .unique(); + + if (!existing) { + const directory = await createDirectoryForAction(ctx, args); + if (directory.currentRevision) args.revisions?.push(directory.currentRevision); + + return directory; + } + + if (existing.kind !== 'directory') throw NotFound(); + + return existing; +} + +async function createUserRootDirectory(ctx: MutationCtx, { owner }: { owner: Id<'users'> }) { + // + const root = await ctx.db.insert('files', { + owner, + parent: 'root', + name: '', + kind: 'directory', + author: owner, + }); + + // root bootstrap is the only direct file mutation path: act() needs a root before normal Reactor actions can exist. + const action = await ctx.db.insert('actions', { + owner, + root, + author: owner, + spark: 'self', + skill: 'bootstrap', + input: { kind: 'root' }, + index: 1, + status: 'succeeded', + finishedAt: Date.now(), + }); + + const revision = await ctx.db.insert('file_revisions', { + owner, + file: root, + action, + changeKind: 'create', + afterPath: '/', + patch: JSON.stringify({ + kind: 'create', + after: { + path: '/', + kind: 'directory', + }, + }), + }); + + await ctx.db.patch(root, { currentRevision: revision }); + await ctx.db.patch(owner, { root }); + await enqueueAction(ctx, { + owner, + root, + author: action, + spark: action, + skill: 'seed', + input: {}, + }); + await ctx.scheduler.runAfter(0, internal.reactor._claimNext, { + owner, + root, + }); + + return root; +} + +async function findCurrentStorageKey(ctx: QueryCtx | MutationCtx, { file }: { file: Doc<'files'> }) { + // + if (!file.currentRevision) return undefined; + + const revision = await ctx.db.get(file.currentRevision); + if (!revision) throw NotFound(); + + return revision.storageKey; +} + +async function findControlDirectory( + ctx: QueryCtx | MutationCtx, + { owner, directory }: { owner: Id<'users'>; directory: Id<'files'> }, +) { + // + const control = await ctx.db + .query('files') + .withIndex('by_owner_parent_name', (q) => + q + .eq('owner', owner) // + .eq('parent', directory) + .eq('name', '.pro'), + ) + .unique(); + + if (!control) return undefined; + if (control.kind !== 'directory') return undefined; + + return control; +} + +function childPath(parentPath: string, name: string) { + // + if (parentPath === '/') return `/${name}`; + + return `${parentPath}/${name}`; +} + +function isRuntimeSourcePath(path: string) { + // + if (path === '/page.tsx') return true; + if (path === '/page.css') return true; + if (!path.includes('/.pro/') && path.endsWith('/page.tsx')) return true; + if (!path.includes('/.pro/') && path.endsWith('/page.css')) return true; + if (path === '/.pro/settings.json') return true; + if (path.startsWith('/.pro/skills/') && path.endsWith('.ts')) return true; + if (path.startsWith('/.pro/triggers/') && path.endsWith('.ts')) return true; + if (path.startsWith('/.pro/components/') && path.endsWith('.tsx')) return true; + + return false; +} + +async function ensureChildNameAvailable( + ctx: QueryCtx | MutationCtx, + { owner, parent, name }: { owner: Id<'users'>; parent: Id<'files'>; name: string }, +) { + // + const existing = await ctx.db + .query('files') + .withIndex('by_owner_parent_name', (q) => + q + .eq('owner', owner) // + .eq('parent', parent) + .eq('name', name), + ) + .unique(); + + if (existing) throw new Error('A file already exists at this path.'); +} diff --git a/apps/meseeks/convex/files.ts b/apps/meseeks/convex/files.ts new file mode 100644 index 00000000..9b42bb09 --- /dev/null +++ b/apps/meseeks/convex/files.ts @@ -0,0 +1,132 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { internal } from 'convex/_generated/api'; +import { action, internalQuery, mutation, query } from 'lib/convex'; +import { NotFound } from 'lib/errors'; +import { getCurrentUser } from './users.private'; +import { ensureFileOwner, ensureUserRootDirectory, findFile, listFileChildren } from './files.private'; +import { createReadUrl as createBodyReadUrl } from './storage.private'; + +export const findRoot = query({ + args: {}, + handler: async (ctx) => { + // + const currentUser = await getCurrentUser(ctx, {}); + if (!currentUser.root) throw NotFound(); + + return await findFile(ctx, { file: currentUser.root }); + }, +}); + +export const ensureUserRoot = mutation({ + args: {}, + handler: async (ctx) => { + // + const currentUser = await getCurrentUser(ctx, {}); + + return await ensureUserRootDirectory(ctx, { owner: currentUser._id }); + }, +}); + +export const list = query({ + args: { + parent: zid('files'), + }, + handler: async (ctx, { parent }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + + return await listFileChildren(ctx, { owner: currentUser._id, parent }); + }, +}); + +export const listRevisions = query({ + args: { + file: zid('files'), + }, + handler: async (ctx, { file }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + await ensureFileOwner(ctx, { + owner: currentUser._id, + file, + }); + + return await ctx.db + .query('file_revisions') + .withIndex('by_owner_file', (q) => + q + .eq('owner', currentUser._id) // + .eq('file', file), + ) + .order('desc') + .collect(); + }, +}); + +export const listRevisionsByAction = query({ + args: { + action: zid('actions'), + }, + handler: async (ctx, { action }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + const row = await ctx.db.get(action); + if (!row) throw NotFound(); + if (row.owner !== currentUser._id) throw NotFound(); + + return await ctx.db + .query('file_revisions') + .withIndex('by_action', (q) => q.eq('action', action)) + .collect(); + }, +}); + +export const find = query({ + args: { + file: zid('files'), + }, + handler: async (ctx, args) => { + // + const currentUser = await getCurrentUser(ctx, {}); + + return await ensureFileOwner(ctx, { + owner: currentUser._id, + file: args.file, + }); + }, +}); + +export const createReadUrl = action({ + args: { + file: zid('files'), + }, + handler: async (ctx, { file }): Promise<{ readUrl: string; expiresAt: number }> => { + // + const { storageKey }: { storageKey: string } = await ctx.runQuery(internal.files._findReadStorageKey, { + file, + }); + + return await createBodyReadUrl({ storageKey }); + }, +}); + +export const _findReadStorageKey = internalQuery({ + args: { + file: zid('files'), + }, + handler: async (ctx, { file }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + const row = await ensureFileOwner(ctx, { + owner: currentUser._id, + file, + }); + if (row.kind !== 'file') throw NotFound(); + if (!row.currentRevision) throw NotFound(); + + const revision = await ctx.db.get(row.currentRevision); + if (!revision?.storageKey) throw NotFound(); + + return { storageKey: revision.storageKey }; + }, +}); diff --git a/apps/meseeks/convex/instincts/commitUpload.private.ts b/apps/meseeks/convex/instincts/commitUpload.private.ts new file mode 100644 index 00000000..5d6a9e62 --- /dev/null +++ b/apps/meseeks/convex/instincts/commitUpload.private.ts @@ -0,0 +1,117 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import type { Doc } from 'convex/_generated/dataModel'; +import type { MutationCtx } from 'convex/_generated/server'; +import { defineInstinct } from 'lib/instinct'; +import { findUploadTicket } from '../action/details.private'; +import { findAction } from '../actions.private'; +import { copyBody, createCanonicalStorageKey, findBody } from '../storage.private'; + +export const commitUploadInputSchema = z.object({ + ticketActionId: z.string().min(1), +}); + +const outputSchema = z.object({ + summary: z.string().optional(), + content: z.string(), + contentType: z.string().min(1).optional(), +}); + +const preparationSchema = z.object({ + ticketAction: zid('actions'), + parent: zid('files'), + name: z.string().min(1), + contentType: z.string().min(1), + size: z.number().int().min(0), + hash: z.string().min(1), + checksum: z.string().min(1), + stagedStorageKey: z.string().min(1), +}); + +export async function prepareCommitUpload( + ctx: MutationCtx, + { action, input }: { action: Doc<'actions'>; input: z.infer }, +) { + // + const ticketActionId = zid('actions').parse(input.ticketActionId); + const ticketAction = await findAction(ctx, { action: ticketActionId }); + if (ticketAction.owner !== action.owner) throw new Error('Upload ticket belongs to a different user.'); + if (ticketAction.skill !== 'prepareUpload') throw new Error('Upload ticket action is not a prepareUpload action.'); + if (ticketAction.status !== 'succeeded') throw new Error('Upload ticket action has not succeeded.'); + + const ticket = await findUploadTicket(ctx, { action: ticketActionId }); + const now = Date.now(); + + return { + action: action._id, + owner: action.owner, + createdAt: now, + kind: 'preparation', + skill: action.skill, + skillKind: 'instinct', + preparedAt: now, + context: { + ticketAction: ticket.ticketAction, + parent: ticket.parent, + name: ticket.name, + contentType: ticket.contentType, + size: ticket.size, + hash: ticket.hash, + checksum: ticket.checksum, + stagedStorageKey: ticket.stagedStorageKey, + }, + }; +} + +export const commitUpload = defineInstinct({ + key: 'commitUpload', + description: 'Commit a prepared Object Storage upload into the file tree.', + inputSchema: commitUploadInputSchema, + outputSchema, + async perform({ action, preparation, warnings }, { stageText }) { + // + const ticket = preparationSchema.parse(preparation); + const staged = await findBody({ storageKey: ticket.stagedStorageKey }); + if (staged.contentLength !== ticket.size) { + throw new Error( + `Uploaded object size mismatch: expected ${ticket.size}, found ${staged.contentLength ?? 'unknown'}.`, + ); + } + if (staged.checksum && staged.checksum !== ticket.checksum) { + throw new Error('Uploaded object checksum mismatch.'); + } + + const storageKey = createCanonicalStorageKey({ owner: action.owner }); + await copyBody({ + from: ticket.stagedStorageKey, + to: storageKey, + contentType: ticket.contentType, + }); + + const output = await stageText({ + owner: action.owner, + content: `Committed upload ${ticket.name}.`, + contentType: 'text/mdx; charset=utf-8', + }); + + return { + action: action._id, + status: 'succeeded', + output, + fileMutations: [ + { + kind: 'createFile', + parent: ticket.parent, + name: ticket.name, + body: { + contentType: ticket.contentType, + hash: ticket.hash, + size: ticket.size, + storageKey, + }, + }, + ], + warnings, + }; + }, +}); diff --git a/apps/meseeks/convex/instincts/compile.private.ts b/apps/meseeks/convex/instincts/compile.private.ts new file mode 100644 index 00000000..fc89155d --- /dev/null +++ b/apps/meseeks/convex/instincts/compile.private.ts @@ -0,0 +1,35 @@ +import { z } from 'zod/v3'; +import { compilePreparedSources } from '../compile.private'; +import { defineInstinct } from 'lib/instinct'; + +const inputSchema = z.object({}); + +const outputSchema = z.object({ + summary: z.string().optional(), + content: z.string(), + contentType: z.string().min(1).optional(), +}); + +export const compile = defineInstinct({ + key: 'compile', + description: 'Compile file-authored runtime source into executable runtime state.', + inputSchema, + outputSchema, + async perform({ action, preparation, warnings }, { stageText }) { + // + const result = await compilePreparedSources(preparation); + const output = await stageText({ + owner: action.owner, + content: result.content, + contentType: 'text/mdx; charset=utf-8', + }); + + return { + action: action._id, + status: 'succeeded', + output, + compileMutations: [result.mutation], + warnings, + }; + }, +}); diff --git a/apps/meseeks/convex/instincts/create.private.ts b/apps/meseeks/convex/instincts/create.private.ts new file mode 100644 index 00000000..1ec7f2e5 --- /dev/null +++ b/apps/meseeks/convex/instincts/create.private.ts @@ -0,0 +1,76 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import { defineInstinct } from 'lib/instinct'; + +const inputSchema = z.object({ + kind: z.enum([ + 'file', // + 'directory', + ]), + parentId: z.string().min(1).optional(), + name: z + .string() + .min(1) + .regex(/^[^/\\]+$/, 'Name must not contain slashes.'), + content: z.string().optional(), + contentType: z.string().min(1).optional(), +}); + +const outputSchema = z.object({ + summary: z.string().optional(), + content: z.string(), + contentType: z.string().min(1).optional(), +}); + +export const create = defineInstinct({ + key: 'create', + description: 'Create a file or directory.', + inputSchema, + outputSchema, + async perform({ action, input, warnings }, { stageText }) { + // + const parent = input.parentId ? zid('files').parse(input.parentId) : action.root; + const output = await stageText({ + owner: action.owner, + content: input.kind === 'directory' ? `Created directory ${input.name}.` : `Created file ${input.name}.`, + contentType: 'text/mdx; charset=utf-8', + }); + + if (input.kind === 'directory') { + return { + action: action._id, + status: 'succeeded', + output, + fileMutations: [ + { + kind: 'createDirectory', + parent, + name: input.name, + }, + ], + warnings, + }; + } + + const body = await stageText({ + owner: action.owner, + content: input.content ?? '', + contentType: input.contentType ?? 'text/plain; charset=utf-8', + }); + + return { + action: action._id, + status: 'succeeded', + output, + fileMutations: [ + { + kind: 'createText', + parent, + name: input.name, + body, + }, + ], + warnings, + }; + }, +}); diff --git a/apps/meseeks/convex/instincts/createTrigger.private.ts b/apps/meseeks/convex/instincts/createTrigger.private.ts new file mode 100644 index 00000000..2558c5c8 --- /dev/null +++ b/apps/meseeks/convex/instincts/createTrigger.private.ts @@ -0,0 +1,98 @@ +import { z } from 'zod/v3'; +import { newActionSchema } from 'schemas/actionSchema'; +import { fileRevisionChangeKindSchema } from 'schemas/fileRevisionSchema'; +import { defineInstinct } from 'lib/instinct'; + +const inputSchema = z.object({ + events: z.array(fileRevisionChangeKindSchema).min(1), + pattern: z.string().optional(), + reactions: z.array(newActionSchema).min(1).max(5), +}); + +const outputSchema = z.object({ + summary: z.string().optional(), +}); + +export const createTrigger = defineInstinct({ + key: 'createTrigger', + description: 'Create a mutation trigger source file for the current root.', + inputSchema, + outputSchema, + async perform({ action, input, warnings }, { stageText }) { + // + const reaction = input.reactions[0]; + if (!reaction) throw new Error('Trigger source requires a reaction.'); + const path = ['.pro', 'triggers', `trigger-${String(action.index).padStart(6, '0')}.ts`]; + const nextWarnings = warnings.concat(triggerSourceWarnings(input)); + const source = await stageText({ + owner: action.owner, + content: triggerSource({ + events: input.events, + pattern: input.pattern, + reaction, + }), + contentType: 'text/typescript; charset=utf-8', + }); + const output = await stageText({ + owner: action.owner, + content: [ + '# Create trigger', + '', + `- created \`/${path.join('/')}\``, + '- trigger rows are derived by `compile`', + ].join('\n'), + contentType: 'text/mdx; charset=utf-8', + }); + + return { + action: action._id, + status: 'succeeded', + output, + fileMutations: [ + { + kind: 'createTextAtPath', + parent: action.root, + path, + body: source, + }, + ], + warnings: nextWarnings, + }; + }, +}); + +function triggerSource({ + events, + pattern, + reaction, +}: { + events: Array; + pattern?: string; + reaction: z.infer; +}) { + // + const lines = [ + 'export default defineTrigger({', + `\tevents: [${events.map((event) => JSON.stringify(event)).join(', ')}],`, + ]; + + if (pattern) lines.push(`\tpattern: ${JSON.stringify(pattern)},`); + + lines.push(`\tskill: ${JSON.stringify(reaction.skill)},`); + if (typeof reaction.input.message === 'string') lines.push(`\tmessage: ${JSON.stringify(reaction.input.message)},`); + lines.push('\tmaxUses: Infinity,', '});', ''); + + return lines.join('\n'); +} + +function triggerSourceWarnings({ reactions }: z.infer) { + // + const warnings: Array = []; + if (reactions.length > 1) warnings.push('Only the first reaction was written to trigger source.'); + const reaction = reactions[0]; + if (reaction && reaction.skill !== 'compile' && typeof reaction.input.message !== 'string') { + warnings.push('Only message input is supported by trigger source compilation right now.'); + } + + return warnings; +} diff --git a/apps/meseeks/convex/instincts/disableTrigger.private.ts b/apps/meseeks/convex/instincts/disableTrigger.private.ts new file mode 100644 index 00000000..a1180671 --- /dev/null +++ b/apps/meseeks/convex/instincts/disableTrigger.private.ts @@ -0,0 +1,34 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import { defineInstinct } from 'lib/instinct'; + +const inputSchema = z.object({ + triggerId: z.string().min(1), +}); + +const outputSchema = z.object({ + summary: z.string().optional(), +}); + +export const disableTrigger = defineInstinct({ + key: 'disableTrigger', + description: 'Disable a trigger.', + inputSchema, + outputSchema, + perform({ action, input, warnings }) { + // + const trigger = zid('triggers').parse(input.triggerId); + + return { + action: action._id, + status: 'succeeded', + triggerMutations: [ + { + kind: 'disableTrigger', + trigger, + }, + ], + warnings, + }; + }, +}); diff --git a/apps/meseeks/convex/instincts/execute.private.ts b/apps/meseeks/convex/instincts/execute.private.ts new file mode 100644 index 00000000..077657b6 --- /dev/null +++ b/apps/meseeks/convex/instincts/execute.private.ts @@ -0,0 +1,35 @@ +import { z } from 'zod/v3'; +import { executePreparationSchema, performExecute } from '../execute.private'; +import { defineInstinct } from 'lib/instinct'; + +export const executeInputSchema = z.object({ + code: z.string().min(1).max(10_000), + language: z + .enum([ + 'javascript', // + 'python', + ]) + .default('javascript'), +}); + +const outputSchema = z.object({ + summary: z.string().optional(), + content: z.string(), + contentType: z.string().min(1).optional(), +}); + +export const execute = defineInstinct({ + key: 'execute', + description: 'Run code in an execution box.', + inputSchema: executeInputSchema, + outputSchema, + async perform({ action, preparation, warnings }, { stageText }) { + // + return await performExecute({ + action, + preparation: executePreparationSchema.parse(preparation), + stageText, + warnings, + }); + }, +}); diff --git a/apps/meseeks/convex/instincts/index.private.ts b/apps/meseeks/convex/instincts/index.private.ts new file mode 100644 index 00000000..e321a0ab --- /dev/null +++ b/apps/meseeks/convex/instincts/index.private.ts @@ -0,0 +1,64 @@ +import { instinctSchema } from 'schemas/skillSchema'; +import { zodToString } from 'lib/zodToString'; +import { commitUpload } from './commitUpload.private'; +import { compile } from './compile.private'; +import { create } from './create.private'; +import { createTrigger } from './createTrigger.private'; +import { disableTrigger } from './disableTrigger.private'; +import { execute } from './execute.private'; +import { interrupt } from './interrupt.private'; +import { move } from './move.private'; +import { prepareUploadInstinct } from './prepareUpload.private'; +import { request } from './request.private'; +import { say } from './say.private'; +import { seed } from './seed.private'; +import { tag } from './tag.private'; +import { think } from './think.private'; +import { untag } from './untag.private'; +import { write } from './write.private'; + +export const instincts = { + say, + think, + request, + execute, + create, + write, + move, + tag, + untag, + interrupt, + seed, + prepareUpload: prepareUploadInstinct, + commitUpload, + createTrigger, + disableTrigger, + compile, +}; + +export type InstinctKey = keyof typeof instincts; + +export function isInstinctKey(key: string): key is InstinctKey { + // + return key in instincts; +} + +export function findInstinct(key: string) { + // + if (!isInstinctKey(key)) return undefined; + + return instincts[key]; +} + +export function listInstincts() { + // + return Object.values(instincts).map((instinct) => + instinctSchema.parse({ + key: instinct.key, + kind: instinct.key, + description: instinct.description, + inputSchema: zodToString(instinct.inputSchema), + outputSchema: zodToString(instinct.outputSchema), + }), + ); +} diff --git a/apps/meseeks/convex/instincts/interrupt.private.ts b/apps/meseeks/convex/instincts/interrupt.private.ts new file mode 100644 index 00000000..3c2bf255 --- /dev/null +++ b/apps/meseeks/convex/instincts/interrupt.private.ts @@ -0,0 +1,26 @@ +import { z } from 'zod/v3'; +import { defineInstinct } from 'lib/instinct'; + +const inputSchema = z.object({ + actionId: z.string().min(1), + reason: z.string().optional(), +}); + +const outputSchema = z.object({ + summary: z.string().optional(), +}); + +export const interrupt = defineInstinct({ + key: 'interrupt', + description: 'Interrupt a running action.', + inputSchema, + outputSchema, + perform({ action, input, warnings }) { + // + return { + action: action._id, + status: 'skipped', + warnings: input.reason ? warnings.concat(input.reason) : warnings, + }; + }, +}); diff --git a/apps/meseeks/convex/instincts/move.private.ts b/apps/meseeks/convex/instincts/move.private.ts new file mode 100644 index 00000000..a0e30800 --- /dev/null +++ b/apps/meseeks/convex/instincts/move.private.ts @@ -0,0 +1,39 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import { defineInstinct } from 'lib/instinct'; + +const inputSchema = z.object({ + fileId: z.string().min(1), + parentId: z.string().min(1).optional(), + name: z.string().min(1).optional(), +}); + +const outputSchema = z.object({ + summary: z.string().optional(), +}); + +export const move = defineInstinct({ + key: 'move', + description: 'Move a file or directory, including renames.', + inputSchema, + outputSchema, + perform({ action, input, warnings }) { + // + const file = zid('files').parse(input.fileId); + const parent = input.parentId ? zid('files').parse(input.parentId) : undefined; + + return { + action: action._id, + status: 'succeeded', + fileMutations: [ + { + kind: 'move', + file, + parent, + name: input.name, + }, + ], + warnings, + }; + }, +}); diff --git a/apps/meseeks/convex/instincts/prepareUpload.private.ts b/apps/meseeks/convex/instincts/prepareUpload.private.ts new file mode 100644 index 00000000..8623d03a --- /dev/null +++ b/apps/meseeks/convex/instincts/prepareUpload.private.ts @@ -0,0 +1,102 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import type { Doc } from 'convex/_generated/dataModel'; +import type { MutationCtx } from 'convex/_generated/server'; +import { defineInstinct } from 'lib/instinct'; +import { createStagedStorageKey, createUploadUrl } from '../storage.private'; +import { ensureDirectoryOwner } from '../files.private'; + +export const prepareUploadInputSchema = z.object({ + parentId: z.string().min(1).optional(), + name: z.string().min(1), + contentType: z.string().min(1), + size: z.number().int().min(0), + hash: z.string().regex(/^[a-f0-9]{64}$/), + checksum: z.string().min(1), +}); + +const outputSchema = z.object({ + summary: z.string().optional(), + content: z.string(), + contentType: z.string().min(1).optional(), +}); + +const preparationSchema = z.object({ + parent: zid('files'), + name: z.string().min(1), + contentType: z.string().min(1), + size: z.number().int().min(0), + hash: z.string().min(1), + checksum: z.string().min(1), +}); + +export async function prepareUpload( + ctx: MutationCtx, + { action, input }: { action: Doc<'actions'>; input: z.infer }, +) { + // + const parent = input.parentId ? zid('files').parse(input.parentId) : action.root; + await ensureDirectoryOwner(ctx, { + owner: action.owner, + directory: parent, + }); + + return { + action: action._id, + owner: action.owner, + createdAt: Date.now(), + kind: 'preparation', + skill: action.skill, + skillKind: 'instinct', + preparedAt: Date.now(), + context: { + parent, + name: input.name, + contentType: input.contentType, + size: input.size, + hash: input.hash, + checksum: input.checksum, + }, + }; +} + +export const prepareUploadInstinct = defineInstinct({ + key: 'prepareUpload', + description: 'Prepare a direct Object Storage upload ticket.', + inputSchema: prepareUploadInputSchema, + outputSchema, + async perform({ action, preparation, warnings }, { stageText }) { + // + const ticket = preparationSchema.parse(preparation); + const stagedStorageKey = createStagedStorageKey({ owner: action.owner }); + const signed = await createUploadUrl({ + storageKey: stagedStorageKey, + contentType: ticket.contentType, + checksum: ticket.checksum, + }); + const output = await stageText({ + owner: action.owner, + content: `Prepared upload ticket for ${ticket.name}.`, + contentType: 'text/mdx; charset=utf-8', + }); + + return { + action: action._id, + status: 'succeeded', + output, + uploadTicket: { + ticketAction: action._id, + parent: ticket.parent, + name: ticket.name, + contentType: ticket.contentType, + size: ticket.size, + hash: ticket.hash, + checksum: ticket.checksum, + stagedStorageKey, + uploadUrl: signed.uploadUrl, + expiresAt: signed.expiresAt, + }, + warnings, + }; + }, +}); diff --git a/apps/meseeks/convex/instincts/request.private.ts b/apps/meseeks/convex/instincts/request.private.ts new file mode 100644 index 00000000..e5cba3ac --- /dev/null +++ b/apps/meseeks/convex/instincts/request.private.ts @@ -0,0 +1,41 @@ +import { z } from 'zod/v3'; +import { performRequest, requestPreparationSchema } from '../request.private'; +import { defineInstinct } from 'lib/instinct'; + +const inputSchema = z.object({ + url: z.string().url(), + method: z + .enum([ + 'GET', // + 'POST', + 'PUT', + 'PATCH', + 'DELETE', + ]) + .optional() + .default('GET'), + headers: z.record(z.string()).optional(), + body: z.unknown().optional(), +}); + +const outputSchema = z.object({ + summary: z.string().optional(), + content: z.string(), + contentType: z.string().min(1).optional(), +}); + +export const request = defineInstinct({ + key: 'request', + description: 'Make an HTTP request.', + inputSchema, + outputSchema, + async perform({ action, preparation, warnings }, { stageText }) { + // + return await performRequest({ + action, + preparation: requestPreparationSchema.parse(preparation), + stageText, + warnings, + }); + }, +}); diff --git a/apps/meseeks/convex/instincts/say.private.ts b/apps/meseeks/convex/instincts/say.private.ts new file mode 100644 index 00000000..13668d17 --- /dev/null +++ b/apps/meseeks/convex/instincts/say.private.ts @@ -0,0 +1,34 @@ +import { z } from 'zod/v3'; +import { defineInstinct } from 'lib/instinct'; + +const inputSchema = z.object({ + message: z.string().default(''), +}); + +const outputSchema = z.object({ + summary: z.string().optional(), + content: z.string(), + contentType: z.string().min(1).optional(), +}); + +export const say = defineInstinct({ + key: 'say', + description: 'Record a message.', + inputSchema, + outputSchema, + async perform({ action, input, warnings }, { stageText }) { + // + const output = await stageText({ + owner: action.owner, + content: input.message || 'Said.', + contentType: 'text/mdx; charset=utf-8', + }); + + return { + action: action._id, + status: 'succeeded', + output, + warnings, + }; + }, +}); diff --git a/apps/meseeks/convex/instincts/seed.private.ts b/apps/meseeks/convex/instincts/seed.private.ts new file mode 100644 index 00000000..795e455e --- /dev/null +++ b/apps/meseeks/convex/instincts/seed.private.ts @@ -0,0 +1,185 @@ +import { z } from 'zod/v3'; +import { defineInstinct } from 'lib/instinct'; + +const inputSchema = z.object({}); + +const outputSchema = z.object({ + summary: z.string().optional(), + content: z.string(), + contentType: z.string().min(1).optional(), +}); + +const rootPage = `export default function Page() { + return
PRO
; +} +`; + +const rootCss = `:root { + color-scheme: light dark; +} +`; + +const settings = `{ + "version": 1 +} +`; + +const compileTrigger = `export default defineTrigger({ + description: 'Compile runtime projections when runtime source files change.', + events: ['create', 'update', 'rename', 'delete'], + patterns: [ + '*page.tsx', + '*page.css', + '/.pro/settings.json', + '/.pro/skills/*.ts', + '/.pro/triggers/*.ts', + '/.pro/components/*.tsx', + ], + skill: 'compile', + maxUses: Infinity, +}); +`; + +const exampleRequestSkill = `export default defineSkill({ + kind: 'request', + description: 'Fetch Example Domain as a safe request smoke test.', + url: 'https://example.com', + method: 'GET', +}); +`; + +const summarizeSkill = `export default defineSkill({ + kind: 'think', + description: 'Summarize provided text or the current directory context.', + instructions: 'Write a concise summary with the important facts and open questions.', + model: 'auto', + temperature: 0.3, +}); +`; + +const reflectSkill = `export default defineSkill({ + kind: 'think', + description: 'Reflect on recent work and identify the next useful step.', + instructions: 'Review the provided context, name the main decision, and suggest one concrete next action.', + model: 'auto', + temperature: 0.5, +}); +`; + +export const seed = defineInstinct({ + key: 'seed', + description: 'Seed the initial runtime files for a new root directory.', + inputSchema, + outputSchema, + async perform({ action, warnings }, { stageText }) { + // + const page = await stageText({ + owner: action.owner, + content: rootPage, + contentType: 'text/tsx; charset=utf-8', + }); + const css = await stageText({ + owner: action.owner, + content: rootCss, + contentType: 'text/css; charset=utf-8', + }); + const settingsSource = await stageText({ + owner: action.owner, + content: settings, + contentType: 'application/json; charset=utf-8', + }); + const compileTriggerSource = await stageText({ + owner: action.owner, + content: compileTrigger, + contentType: 'text/typescript; charset=utf-8', + }); + const exampleRequest = await stageText({ + owner: action.owner, + content: exampleRequestSkill, + contentType: 'text/typescript; charset=utf-8', + }); + const summarize = await stageText({ + owner: action.owner, + content: summarizeSkill, + contentType: 'text/typescript; charset=utf-8', + }); + const reflect = await stageText({ + owner: action.owner, + content: reflectSkill, + contentType: 'text/typescript; charset=utf-8', + }); + const output = await stageText({ + owner: action.owner, + content: [ + '# Seed', + '', + '- created `/page.tsx`', + '- created `/page.css`', + '- created `/.pro/settings.json`', + '- created `/.pro/triggers/compile-on-runtime-source-change.ts`', + '- created `/.pro/skills/example-request.ts`', + '- created `/.pro/skills/summarize.ts`', + '- created `/.pro/skills/reflect.ts`', + '- queued `compile`', + ].join('\n'), + contentType: 'text/mdx; charset=utf-8', + }); + + return { + action: action._id, + status: 'succeeded', + output, + fileMutations: [ + { + kind: 'createText', + parent: action.root, + name: 'page.tsx', + body: page, + }, + { + kind: 'createText', + parent: action.root, + name: 'page.css', + body: css, + }, + { + kind: 'createTextAtPath', + parent: action.root, + path: ['.pro', 'settings.json'], + body: settingsSource, + }, + { + kind: 'createTextAtPath', + parent: action.root, + path: ['.pro', 'triggers', 'compile-on-runtime-source-change.ts'], + body: compileTriggerSource, + }, + { + kind: 'createTextAtPath', + parent: action.root, + path: ['.pro', 'skills', 'example-request.ts'], + body: exampleRequest, + }, + { + kind: 'createTextAtPath', + parent: action.root, + path: ['.pro', 'skills', 'summarize.ts'], + body: summarize, + }, + { + kind: 'createTextAtPath', + parent: action.root, + path: ['.pro', 'skills', 'reflect.ts'], + body: reflect, + }, + ], + reactions: [ + { + skill: 'compile', + input: {}, + }, + ], + warnings, + }; + }, +}); diff --git a/apps/meseeks/convex/instincts/tag.private.ts b/apps/meseeks/convex/instincts/tag.private.ts new file mode 100644 index 00000000..3f03e73f --- /dev/null +++ b/apps/meseeks/convex/instincts/tag.private.ts @@ -0,0 +1,38 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import { defineInstinct } from 'lib/instinct'; + +const inputSchema = z.object({ + fileId: z.string().min(1), + key: z.string().min(1), + value: z.string().optional(), +}); + +const outputSchema = z.object({ + summary: z.string().optional(), +}); + +export const tag = defineInstinct({ + key: 'tag', + description: 'Set a file tag.', + inputSchema, + outputSchema, + perform({ action, input, warnings }) { + // + const file = zid('files').parse(input.fileId); + + return { + action: action._id, + status: 'succeeded', + fileMutations: [ + { + kind: 'tag', + file, + key: input.key, + value: input.value, + }, + ], + warnings, + }; + }, +}); diff --git a/apps/meseeks/convex/instincts/think.private.ts b/apps/meseeks/convex/instincts/think.private.ts new file mode 100644 index 00000000..68cf0a1e --- /dev/null +++ b/apps/meseeks/convex/instincts/think.private.ts @@ -0,0 +1,49 @@ +import { z } from 'zod/v3'; +import { intelligenceKeys } from 'schemas/intelligenceSchema'; +import { askMagicRock, magicRockPreparationSchema } from '../magicRock.private'; +import { defineInstinct } from 'lib/instinct'; + +export const thinkInputSchema = z.object({ + prompt: z.string().min(1), + intelligence: intelligenceKeys.optional(), +}); + +const outputSchema = z.object({ + summary: z.string().optional(), + content: z.string(), + contentType: z.string().min(1).optional(), +}); + +export const think = defineInstinct({ + key: 'think', + description: 'Delegate a decision to a digital intelligence.', + inputSchema: thinkInputSchema, + outputSchema, + async perform({ action, preparation, warnings }, { stageText }) { + // + const magicRock = magicRockPreparationSchema.parse(preparation); + const result = await askMagicRock(magicRock); + const output = await stageText({ + owner: action.owner, + content: result.text, + contentType: 'text/mdx; charset=utf-8', + }); + + return { + action: action._id, + status: 'succeeded', + output, + providerReceipt: { + provider: result.provider, + model: result.model, + request: { + prompt: magicRock.prompt, + system: magicRock.system, + }, + response: { outputLength: result.text.length }, + usage: result.usage, + }, + warnings: warnings.concat(result.warnings), + }; + }, +}); diff --git a/apps/meseeks/convex/instincts/untag.private.ts b/apps/meseeks/convex/instincts/untag.private.ts new file mode 100644 index 00000000..eb8934f5 --- /dev/null +++ b/apps/meseeks/convex/instincts/untag.private.ts @@ -0,0 +1,36 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import { defineInstinct } from 'lib/instinct'; + +const inputSchema = z.object({ + fileId: z.string().min(1), + key: z.string().min(1), +}); + +const outputSchema = z.object({ + summary: z.string().optional(), +}); + +export const untag = defineInstinct({ + key: 'untag', + description: 'Remove a file tag.', + inputSchema, + outputSchema, + perform({ action, input, warnings }) { + // + const file = zid('files').parse(input.fileId); + + return { + action: action._id, + status: 'succeeded', + fileMutations: [ + { + kind: 'untag', + file, + key: input.key, + }, + ], + warnings, + }; + }, +}); diff --git a/apps/meseeks/convex/instincts/write.private.ts b/apps/meseeks/convex/instincts/write.private.ts new file mode 100644 index 00000000..02aacb34 --- /dev/null +++ b/apps/meseeks/convex/instincts/write.private.ts @@ -0,0 +1,102 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import type { Doc } from 'convex/_generated/dataModel'; +import type { MutationCtx } from 'convex/_generated/server'; +import { getFileWriteContext } from '../files.private'; +import { readText } from '../storage.private'; +import { defineInstinct } from 'lib/instinct'; + +const preparationSchema = z.object({ + contentType: z.string().optional(), + path: z.string(), + storageKey: z.string().optional(), +}); + +export const writeInputSchema = z.object({ + fileId: z.string().min(1), + content: z.string(), + contentType: z.string().min(1).optional(), + expectedRevisionId: z.string().min(1).optional(), +}); + +const outputSchema = z.object({ + summary: z.string().optional(), + content: z.string(), + contentType: z.string().min(1).optional(), +}); + +export async function prepareWrite( + ctx: MutationCtx, + { action, input }: { action: Doc<'actions'>; input: z.infer }, +) { + // + const file = zid('files').parse(input.fileId); + const expectedRevision = input.expectedRevisionId + ? zid('file_revisions').parse(input.expectedRevisionId) + : undefined; + const context = await getFileWriteContext(ctx, { + owner: action.owner, + file, + expectedRevision, + }); + const now = Date.now(); + + return { + action: action._id, + owner: action.owner, + createdAt: now, + kind: 'preparation' as const, + skill: action.skill, + skillKind: 'instinct' as const, + preparedAt: now, + context: { + contentType: context.contentType, + path: context.path, + storageKey: context.storageKey, + }, + }; +} + +export const write = defineInstinct({ + key: 'write', + description: 'Write to a text file.', + inputSchema: writeInputSchema, + outputSchema, + async perform({ action, input, preparation, warnings }, { stageText }) { + // + const parsedPreparation = preparationSchema.parse(preparation); + const file = zid('files').parse(input.fileId); + const expectedRevision = input.expectedRevisionId + ? zid('file_revisions').parse(input.expectedRevisionId) + : undefined; + const beforeContent = parsedPreparation.storageKey + ? await readText({ storageKey: parsedPreparation.storageKey }) + : ''; + const body = await stageText({ + owner: action.owner, + content: input.content, + contentType: input.contentType ?? parsedPreparation.contentType ?? 'text/plain; charset=utf-8', + }); + const output = await stageText({ + owner: action.owner, + content: `Wrote ${parsedPreparation.path}.`, + contentType: 'text/mdx; charset=utf-8', + }); + + return { + action: action._id, + status: 'succeeded', + output, + fileMutations: [ + { + kind: 'writeText', + file, + beforeContent, + expectedRevision, + body, + }, + ], + warnings, + }; + }, +}); diff --git a/apps/meseeks/convex/magicRock.private.ts b/apps/meseeks/convex/magicRock.private.ts index cb11ed7c..8bd219fc 100644 --- a/apps/meseeks/convex/magicRock.private.ts +++ b/apps/meseeks/convex/magicRock.private.ts @@ -1,27 +1,29 @@ -import { anthropic } from '@ai-sdk/anthropic'; -import { cerebras } from '@ai-sdk/cerebras'; -import { deepinfra } from '@ai-sdk/deepinfra'; -import { deepseek } from '@ai-sdk/deepseek'; -import { google } from '@ai-sdk/google'; -import { groq } from '@ai-sdk/groq'; -import { openai } from '@ai-sdk/openai'; +import { createDeepSeek } from '@ai-sdk/deepseek'; +import { createOpenAI } from '@ai-sdk/openai'; import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; -import { xai } from '@ai-sdk/xai'; -import { openrouter } from '@openrouter/ai-sdk-provider'; -import { type ModelMessage, generateText, type LanguageModel } from 'ai'; +import { generateText, type LanguageModel } from 'ai'; import { z } from 'zod/v3'; -import type { Doc, Id } from './_generated/dataModel'; -import type { ActionCtx, MutationCtx } from './_generated/server'; -import { isRecord } from 'lib/guards'; -import { asDollars } from 'lib/money'; +import { + DEFAULT_INTELLIGENCE, + INTELLIGENCES, + intelligenceKeys, + type IntelligenceKey, +} from 'schemas/intelligenceSchema'; import { env } from 'schemas/envSchema'; -import type { IntelligenceKey } from 'schemas/intelligenceSchema'; -import type { instructionVariableSchema, softSkillSchema } from 'schemas/skillSchema'; -import type { AITool } from 'schemas/toolSchema'; -import { modelFrom } from 'skills/createAITool'; -import { _toolsForMagicRock } from 'skills/tools'; -import { internal } from './_generated/api'; -import { ACTION_TIMEOUT_MS } from './reactor.constants'; + +const deepseek = createDeepSeek({ + apiKey: env.DEEPSEEK_API_KEY, +}); + +const moonshot = createOpenAICompatible({ + name: 'moonshot', + apiKey: env.MOONSHOT_API_KEY, + baseURL: 'https://api.moonshot.ai/v1', +}); + +const openai = createOpenAI({ + apiKey: env.OPENAI_API_KEY, +}); // >be human // >dig shiny rocks from ground @@ -39,729 +41,102 @@ import { ACTION_TIMEOUT_MS } from './reactor.constants'; // >singularity.png // >mfw humanity peaked by tricking rocks into thinking -const moonshot = createOpenAICompatible({ - name: 'moonshot', - apiKey: env.MOONSHOT_API_KEY, - baseURL: 'https://api.moonshot.ai/v1', +type SelectedModel = { + intelligence: IntelligenceKey; + provider: string; + model: string; + languageModel: LanguageModel; +}; + +export const magicRockPreparationSchema = z.object({ + intelligence: intelligenceKeys, + provider: z.string().min(1), + model: z.string().min(1), + system: z.string().min(1), + prompt: z.string().min(1), + estimated: z.record(z.unknown()).optional(), + warnings: z.array(z.string()).optional(), }); -const inception = createOpenAICompatible({ - name: 'inception', - apiKey: env.INCEPTION_API_KEY, - baseURL: 'https://api.inceptionlabs.ai/v1', -}); +export type MagicRockPreparation = z.infer; -function estimateTokenCount(message: ModelMessage): number { +export function prepareMagicRock({ + intelligence = DEFAULT_INTELLIGENCE, + prompt, +}: { + intelligence?: IntelligenceKey; + prompt: string; +}): MagicRockPreparation { // - let content = ''; - - if (typeof message.content === 'string') { - content = message.content; - } else if (Array.isArray(message.content)) { - // handle TextPart, ImagePart, etc. - for (const part of message.content) { - if ('text' in part && typeof part.text === 'string') { - content += part.text; - } - } - } - - // use the CHAR_PER_TOKEN env variable for estimation - return Math.ceil(content.length / env.CHAR_PER_TOKEN); -} - -export type MagicRockContext = Parameters[0]; - -export async function prepareContext( - ctx: ActionCtx | MutationCtx, - task: Doc<'tasks'>, - action: Doc<'actions'>, - skill: z.infer, -): Promise { - // - const intelligenceKey = modelFrom(skill.config.model, task.preferredIntelligence); - const model = languageModelFrom(intelligenceKey); - - const [history, instructions, tools] = await Promise.all([ - renderHistory(ctx, task, action), - renderInstructions(ctx, task, action, skill), - renderTools(ctx, task, action, skill), - ]); - - console.debug('model', intelligenceKey); - console.debug('instructions', instructions); - - // determine provider from intelligence key - const isAnthropic = intelligenceKey.startsWith('anthropic/'); - const isOpenAI = intelligenceKey.startsWith('openai/'); - const isGoogle = intelligenceKey.startsWith('google/'); - const isXai = intelligenceKey.startsWith('xai/'); - const isGPT5 = intelligenceKey.includes('gpt-5'); - const isGPT5_4 = intelligenceKey.includes('gpt-5.4'); - const isMoonshot = intelligenceKey.startsWith('moonshot/'); - const isDeepSeek = intelligenceKey.startsWith('deepseek/'); - - // TODO: remove those hacks - const temperature = (() => { - // - // those models dont support custom temperatures - if (isGPT5_4) return undefined; - if (isGPT5) return 1; - if (isMoonshot) return 0.6; - - return skill.config.temperature; - })(); - - // build provider options based on the model provider - const providerOptions = { - // OpenAI: disable parallel tool calls - ...(isOpenAI && { openai: { parallelToolCalls: false } }), - // Google: set safety settings to allow all content - ...(isGoogle && { - google: { - safetySettings: [ - { category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_NONE' }, - { - category: 'HARM_CATEGORY_DANGEROUS_CONTENT', - threshold: 'BLOCK_NONE', - }, - { category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' }, - { - category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', - threshold: 'BLOCK_NONE', - }, - { - category: 'HARM_CATEGORY_CIVIC_INTEGRITY', - threshold: 'BLOCK_NONE', - }, - ], - }, - }), - // xAI: set server-side storage - ...(isXai && { xai: { store: true } }), // true is the default - // Kimi: disable reasoning - ...(isMoonshot && { moonshot: { thinking: { type: 'disabled' } } }), - // DeepSeek: disable reasoning - ...(isDeepSeek && { deepseek: { thinking: { type: 'disabled' } } }), - }; + const selected = selectModel(intelligence); return { - model, - temperature, - maxOutputTokens: skill.config.maxTokens ?? undefined, - frequencyPenalty: skill.config.frequencyPenalty ?? undefined, - maxRetries: skill.config.maxRetries ?? undefined, - seed: skill.config.seed ?? undefined, - topK: skill.config.topK ?? undefined, - topP: skill.config.topP ?? undefined, - stopSequences: skill.config.stopSequences ?? undefined, - toolChoice: 'required', - timeout: { totalMs: ACTION_TIMEOUT_MS }, - system: instructions, - messages: isAnthropic - ? [ - { - role: 'system', - content: instructions, - providerOptions: { - // AI SDK says this is on by default, but doesn't look like it is - anthropic: { cacheControl: { type: 'ephemeral' } }, - }, - }, - ...history, - ] - : history, - tools: tools, - providerOptions: Object.keys(providerOptions).length > 0 ? providerOptions : undefined, - }; -} - -export async function askMagicRock(args: MagicRockContext) { - // - const { - finishReason, - text, - toolCalls, - usage, - warnings, - providerMetadata, - // - } = await generateText(args); - - const result = { - finishReason, - text, - // normalize toolCalls: ensure input is always a record - toolCalls: toolCalls.map((call) => { - // - if (!isRecord(call.input)) { - console.warn(`Unexpected non-record tool input for ${call.toolName}:`, typeof call.input, call.input); - return { toolName: call.toolName, input: {} }; - } - - return { toolName: call.toolName, input: call.input }; - }), - usage, - warnings, - providerMetadata, - }; - - console.debug('askMagicRock', result); - - return result; -} - -export function languageModelFrom( - intelligenceKey: IntelligenceKey, // -) { - // - // TODO: move this into @intelligenceSchema.ts - const map: Record = { - // - // Anthropic - 'anthropic/claude-4.5-opus': anthropic('claude-opus-4-5-20251101'), - 'anthropic/claude-4.1-opus': anthropic('claude-opus-4-1-20250805'), - 'anthropic/claude-4.5-sonnet': anthropic('claude-sonnet-4-5-20250929'), - 'anthropic/claude-4.5-haiku': anthropic('claude-haiku-4-5-20251001'), - 'anthropic/claude-4-opus': anthropic('claude-4-opus-20250514'), - 'anthropic/claude-4-sonnet': anthropic('claude-4-sonnet-20250514'), - 'anthropic/claude-3.7-sonnet': anthropic('claude-4-sonnet-20250514'), // 3.7 kept for retro-compatibility - 'anthropic/claude-3.5-haiku': anthropic('claude-3-5-haiku-latest'), - - // OpenAI - 'openai/gpt-5.5': openai('gpt-5.5'), - 'openai/gpt-5.4': openai('gpt-5.4'), - 'openai/gpt-5': openai('gpt-5'), - 'openai/gpt-5-mini': openai('gpt-5-mini'), - 'openai/gpt-5-nano': openai('gpt-5-nano'), - 'openai/gpt-4.1': openai('gpt-4.1'), - 'openai/gpt-4.1-mini': openai('gpt-4.1-mini'), - 'openai/gpt-4.1-nano': openai('gpt-4.1-nano'), - 'openai/gpt-oss-120b': openrouter('openai/gpt-oss-120b'), - 'openai/gpt-oss-20b': openrouter('openai/gpt-oss-20b'), - - // Google - 'google/gemini-2.5-pro': google('gemini-2.5-pro'), - 'google/gemini-2.5-flash': google('gemini-2.5-flash'), - 'google/gemini-2.5-flash-lite': google('gemini-2.5-flash-lite'), - - // xAI - 'xai/grok-4.1-fast-non-reasoning': xai.responses('grok-4-1-fast-non-reasoning'), - 'xai/grok-4': xai.responses('grok-4-0709'), - 'xai/grok-4-fast-non-reasoning': xai.responses('grok-4-fast-non-reasoning'), - 'xai/grok-build-0.1': xai.responses('grok-build-0.1'), - 'xai/grok-code-fast-1': xai.responses('grok-code-fast-1-0825'), - 'xai/grok-3': xai.responses('grok-3'), - 'xai/grok-3-mini': xai.responses('grok-3-mini'), - - // Groq - 'groq/qwen3-32b': groq('qwen/qwen3-32b'), - - // DeepSeek - 'deepseek/deepseek-v4-pro': deepseek('deepseek-v4-pro'), - 'deepseek/deepseek-v4-flash': deepseek('deepseek-v4-flash'), - 'deepseek/deepseek-v3': deepseek('deepseek-chat'), - - // Moonshot - 'moonshot/kimi-2': moonshot('kimi-k2-0905-preview'), - 'moonshot/kimi-2.5': moonshot('kimi-k2.5'), - // 'moonshot/kimi-2': groq('moonshotai/kimi-k2-instruct'), - - // Inception Labs - 'inception/mercury-2': inception('mercury-2'), - - // Cerebras - 'cerebras/qwen3-235b': cerebras('qwen-3-235b-a22b'), - 'cerebras/zai-glm-4.7': cerebras('zai-glm-4.7'), - 'cerebras/zai-glm-4.6': cerebras('zai-glm-4.6'), - - // DeepInfra - 'deepinfra/qwen-3-coder': deepinfra('Qwen/Qwen3-Coder-480B-A35B-Instruct'), - 'deepinfra/glm-4.5': deepinfra('zai-org/GLM-4.5-Air'), - - // OpenRouter - 'openrouter/qwen-3-coder': openrouter('openrouter/horizon-alpha'), - 'openrouter/GLM-4.5-Air': openrouter('z-ai/glm-4.5-air'), - 'openrouter/GLM-4.5': openrouter('z-ai/glm-4.5'), - - // Together - // 'together/llama-4-maverick': togetherai('meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8'), + intelligence: selected.intelligence, + provider: selected.provider, + model: selected.model, + system: 'You are PRO. Return concise, useful MDX for the current directory.', + prompt, }; - - if (intelligenceKey in map) return map[intelligenceKey]; - - throw new Error(`Unknown model: ${intelligenceKey}`); - - // EXPERIMENTS - // model: anthropic('claude-4-sonnet-20250514'), // <---- AGI - // model: openai('gpt-4o', { parallelToolCalls: false }), // 2nd best - // return groq('llama-3.3-70b-versatile'); // mei burro, mas tem potencial - // return togetherai('meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo'); - // return togetherai('google/gemma-2-27b-it'); - // return togetherai('Qwen/Qwen2.5-72B-Instruct-Turbo'); - // return togetherai('mistralai/Mistral-7B-Instruct-v0.3'); - // return togetherai('meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo'); - // return groq('deepseek-r1-distill-llama-70b'); - // return deepinfra('deepseek-ai/DeepSeek-R1'); - // return deepinfra('microsoft/Phi-4-multimodal-instruct'); - // return deepinfra('google/gemma-2-27b-it'); - // model: google('gemma-3-27b-it'), - // model: ollama('phi4-mini'), - // model: ollama('gemma3:4b'), - // model: anthropic('claude-3-5-haiku-20241022'), // ok, but very far from Sonnet - // model: deepseek('deepseek-reasoner'), // complete failure, reasoner can't call tools - // model: google('gemini-2.0-flash-001'), // useful for some tools, can search using Google - // model: openai('o3-mini', { // suprisingly bad, worse than GPT-4o on every test - // reasoningEffort: 'low', - // structuredOutputs: false, // if setting to true, it gets more strict on tool schemas and disable parallel tool calls - // }), } -async function renderTools( - ctx: ActionCtx | MutationCtx, // - task: Doc<'tasks'>, - action: Doc<'actions'>, - skill: z.infer, -): Promise> { +export async function askMagicRock(preparation: MagicRockPreparation) { // - const availableSkills = [ - ...new Set( // as a Set to avoid duplicates - skill.config.availableSkills.flatMap((skillItem) => { - // - if (skillItem === '{{taskSkills}}') return task.availableSkills ?? []; - // TODO: support for more variables, better abstraction - - return skillItem; - }), - ), - ]; - - console.debug('loading tools, config:', availableSkills); - - // TODO: optimize - const allTools = await _toolsForMagicRock(ctx, task, action); - const tools = Object.fromEntries(Object.entries(allTools).filter(([key]) => availableSkills.includes(key))); - - console.debug('loaded tools', Object.keys(tools)); - - if (Object.keys(tools).length !== availableSkills.length) { - console.warn( - 'missing tools', - availableSkills.filter((key) => !tools[key]), - ); - } - - return tools; -} - -function cropHistoryToTokenLimit(history: ModelMessage[], maxTokens: number = env.MAX_CONTEXT_TOKENS): ModelMessage[] { - // - const totalTokens = history.reduce((sum, message) => sum + estimateTokenCount(message), 0); - - console.debug('cropHistoryToTokenLimit', totalTokens, maxTokens); - if (totalTokens <= maxTokens || history.length === 0) return history; - - // remove oldest message and recurse - const [, ...remaining] = history; - const removedTokens = estimateTokenCount(history[0]); - - console.debug( - `Cropped oldest message.`, - `Removed ${removedTokens} tokens.`, - `Remaining: ${totalTokens - removedTokens} tokens, ${remaining.length} messages.`, - ); - - return cropHistoryToTokenLimit(remaining); -} - -async function renderHistory( - ctx: ActionCtx | MutationCtx, // - task: Doc<'tasks'>, - action: Doc<'actions'>, -): Promise> { - // - const actions = await ctx.runQuery(internal.action._findLastActions, { - taskId: task._id, - amount: env.MAX_CONTEXT_ACTIONS, + const parsed = magicRockPreparationSchema.parse(preparation); + const selected = selectModel(parsed.intelligence); + const result = await generateText({ + model: selected.languageModel, + system: parsed.system, + prompt: parsed.prompt, + providerOptions: + selected.provider === 'DeepSeek' || selected.provider === 'Moonshot' + ? { [selected.provider.toLowerCase()]: { thinking: { type: 'disabled' } } } + : undefined, }); - // filter, render, crop and flatten - const history = cropHistoryToTokenLimit( - actions - // remove unfinished or skipped actions - .filter((action: Doc<'actions'>) => - ['succeeded', 'failed', 'pending authorization'].includes(action.status), - ) - // remove the current action - .filter((a: Doc<'actions'>) => a._id !== action._id) - // reverse to show the most recent actions last - .reverse() - // render each action - .map((action: Doc<'actions'>) => renderAction(action, task.owner === action.author)) - // filter out undefined - .filter( - ( - rendered: ModelMessage | Array | undefined, - ): rendered is ModelMessage | Array => rendered !== undefined, - ) - // flatten - .flatMap((message: ModelMessage | Array) => message), - ); - - const finalTokens = history.reduce((sum, message) => sum + estimateTokenCount(message), 0); - - console.debug( - `Rendered last ${history.length} actions as history (${finalTokens} tokens, max ${env.MAX_CONTEXT_TOKENS})`, - ); - - return history; -} - -function renderAction( - action: Doc<'actions'>, // - isUser: boolean, -): ModelMessage | Array | undefined { - // - // temporary until tasks/backlog/jsx-for-ai.mdx replaces this with per-skill ai history components. - if ((action.skillKey === 'iterate' || action.skillKey === 'instruct') && !action.result?.text) return; - return { - role: isUser ? 'user' : 'assistant', - content: [ - `${new Date(action._creationTime).toISOString()}`, - `${action.skillKey}`, - `${action.status}`, - action.result?.text ? `${action.result?.text}` : '', - // `${action.costs.reduce}`, - ].join(''), + text: result.text, + provider: selected.provider, + model: selected.model, + usage: { + inputTokens: result.usage.inputTokens, + outputTokens: result.usage.outputTokens, + totalTokens: result.usage.totalTokens, + }, + warnings: (result.warnings ?? []).map((warning) => JSON.stringify(warning)), }; } -// function computeSince( -// task: Doc<'tasks'>, // -// skill: z.infer, -// ) { -// // -// switch (skill.config.historyMode) { -// // -// case 'since last instructed': -// return task.lastUpdatedAt ?? 0; - -// case 'all': -// return 0; -// } -// } - -async function renderInstructions( - ctx: ActionCtx | MutationCtx, // - task: Doc<'tasks'>, // - action: Doc<'actions'>, - skill: z.infer, -) { - // - let result = skill.config.instructions; - - // TODO: workaround because we needed an async - result = await replaceAllSkillsIfNeeded(ctx, task.owner, result); - result = await replaceActiveSkillsIfNeeded(ctx, task.owner, result); - result = await replaceActiveTasksIfNeeded(ctx, task.owner, result); - - // Handle async variables - const userInfo = await getUserInfoIfNeeded(ctx, task.owner, result); - const taskSchedules = await getTaskSchedulesIfNeeded(ctx, task._id, result); - - // Single-pass parsing that handles both escaped and normal variables correctly - result = parseAndReplaceVariables(result, task, action, userInfo, taskSchedules); - - return result; -} - -function parseAndReplaceVariables( - text: string, - task: Doc<'tasks'>, - action: Doc<'actions'>, - userInfo?: string, - taskSchedules?: string, -): string { - // - // Use a single regex that captures all {{...}} patterns and distinguishes escaped from normal - return text.replace(/\{\{(\\?)([^{}]+)\}\}/g, (match, backslash, variableName) => { - // - // If there's a backslash, it's escaped - return the literal {{variable}} - if (backslash) { - return `{{${variableName}}}`; - } - - // Normal variable - replace with value - const trimmedVariable = variableName.trim(); - const replacedValue = valueForVariable(trimmedVariable, task, action, userInfo, taskSchedules); - - // If the replacement contains {{}} patterns, we need to process them recursively - // but only if they're different from the original to avoid infinite loops - if (replacedValue !== match && replacedValue.includes('{{')) { - return parseAndReplaceVariables(replacedValue, task, action, userInfo, taskSchedules); - } - - return replacedValue; - }); -} - -async function replaceAllSkillsIfNeeded( - ctx: ActionCtx | MutationCtx, // - userId: Id<'users'>, - text: string, -): Promise { +function selectModel(intelligence: IntelligenceKey): SelectedModel { // - if (!text.includes('{{allSkills}}')) return text; - - const list = await ctx.runQuery(internal.skills._findAllKeys, { - userId, - }); + const definition = INTELLIGENCES[intelligence]; + const model = intelligence.split('/')[1]; - const variable = list - .map((item: { key: string; description: string }) => `- *${item.key}*: ${item.description}`) - .join('\n'); - - return text.replace('{{allSkills}}', variable); -} - -async function replaceActiveSkillsIfNeeded( - ctx: ActionCtx | MutationCtx, // - userId: Id<'users'>, - text: string, -): Promise { - // - if (!text.includes('{{activeSkills}}')) return text; - - const enabledSkills = await ctx.runQuery(internal.skills._findEnabledSkillsWithDetails, { - userId, - }); - - const variable = enabledSkills - .map( - (skill: { key: string; description: string; inputSchema: string }) => - `- **${skill.key}**: ${skill.description}\n Input schema: \`${skill.inputSchema}\``, - ) - .join('\n'); - - return text.replace('{{activeSkills}}', variable); -} - -async function replaceActiveTasksIfNeeded( - ctx: ActionCtx | MutationCtx, // - userId: Id<'users'>, - text: string, -): Promise { - // - if (!text.includes('{{activeTasks}}')) return text; - - const limit = env.ACTIVE_TASKS_RENDER_LIMIT; - const activeTasks = await ctx.runQuery(internal.tasks._findActiveTasks, { - owner: userId, - limit, - }); - - // Sort by total budget (highest first) - const sortedTasks = activeTasks.sort( - ( - a: { - energyBudget: { total: bigint }; - }, - b: { - energyBudget: { total: bigint }; - }, - ) => Number(b.energyBudget.total - a.energyBudget.total), - ); - - const variable = - '1 energy === 1 US dollar\n' + - sortedTasks - .map((task: Doc<'tasks'>) => { - const title = task.title || 'Untitled'; - const totalBudget = asDollars({ - bigInt: task.energyBudget.total, - precision: 2, - }); - const createdAt = dateOrNever(task._creationTime); - return `- *${title}* (id: ${task._id}, ${totalBudget} energy, created: ${createdAt})`; - }) - .join('\n'); - - return text.replace('{{activeTasks}}', variable || 'No active tasks found.'); -} - -function valueForVariable( - variable: z.infer, // - task: Doc<'tasks'>, - action: Doc<'actions'>, - userInfo?: string, - taskSchedules?: string, -): string { - // - switch (variable) { - // - case 'task': - return [ - `{{task.id}}`, // - `{{task.title}}`, - `{{task.status}}`, - `{{task.createdAt}}`, - `{{task.lastUpdatedAt}}`, - `{{task.energyBudget}}`, - `{{task.instructions}}`, - `{{task.summary}}`, - // `${task.parent}`, - ] - .join('') - .replaceAll('\t', ''); - - case 'task.id': - return task._id; - - case 'task.title': - return task.title ?? 'no title'; - - case 'task.status': - return task.status; - - case 'task.createdAt': - return dateOrNever(task._creationTime); - - case 'task.lastUpdatedAt': - return dateOrNever(task.lastUpdatedAt); - - case 'task.instructions': - return task.instructions ?? 'no instructions'; - - case 'task.summary': - return task.summary ?? 'no summary'; - - case 'task.parent': - return task.parentId ?? 'no parent'; - - case 'task.energyBudget': - return [ - `{{task.energyBudget.total}}`, - `{{task.energyBudget.spent}}`, - `{{task.energyBudget.available}}`, - ].join(''); - - case 'task.energyBudget.total': - return asDollars({ bigInt: task.energyBudget.total, precision: 10 }); - - case 'task.energyBudget.spent': - return asDollars({ - bigInt: task.energyBudget.total - task.energyBudget.available, - precision: 10, - }); - - case 'task.energyBudget.available': - return asDollars({ bigInt: task.energyBudget.available, precision: 10 }); - - case 'taskSchedules': - return taskSchedules ?? 'No active schedules for this task.'; - - case 'currentDate': - return new Date().toISOString(); - - case 'userInfo': - return ( - userInfo || - 'No user information available. Use setUserInfo skill to add personal details.' - ); - - default: - // input.* variables - if (variable.startsWith('input.')) { - // - const argName = variable.slice(6); // remove 'input.' prefix - const value = action.args[argName]; - - if (value === undefined) { - return `no ${argName}`; - } - - // convert value to string representation - if (typeof value === 'string') { - return value; - } else if (typeof value === 'object') { - return JSON.stringify(value, null, 2); - } else { - return String(value); - } - } - - console.warn(`Unknown variable: ${variable}`); - - return variable; + if (!model) { + throw new Error(`Invalid intelligence key: ${intelligence}`); } -} - -async function getUserInfoIfNeeded( - ctx: ActionCtx | MutationCtx, - userId: Id<'users'>, - text: string, -): Promise { - // - if (!text.includes('{{userInfo}}')) return undefined; - - const userInfoPreference = await ctx.runQuery(internal.users.preferences._getUserPreference, { - userId, - key: 'userInfo', - }); - - return ( - userInfoPreference?.value || - 'No user information available. Use setUserInfo skill to add personal details.' - ); -} - -async function getTaskSchedulesIfNeeded( - ctx: ActionCtx | MutationCtx, - taskId: Id<'tasks'>, - text: string, -): Promise { - // - if (!text.includes('{{taskSchedules}}')) return undefined; - try { - const schedules = await ctx.runQuery(internal.schedules._findByTask, { - taskId, - }); - - if (schedules.length === 0) { - return 'No active schedules for this task.'; - } - - return schedules - .map((schedule: Doc<'schedules'>) => { - const nextRun = new Date(schedule.nextRunAt).toISOString(); - const type = schedule.scheduleType === 'one-time' ? 'One-time' : 'Recurring'; - const details = - schedule.scheduleType === 'one-time' - ? `at ${nextRun}` - : `cron: ${schedule.cronExpression}, next: ${nextRun}`; - - return [ - ``, - `${schedule._id}`, - `${type}`, - `${schedule.skillKey}`, - `
${details}
`, - `${schedule.timeZone}`, - `
`, - ].join(''); - }) - .join(''); - } catch (error) { - console.error('Failed to fetch task schedules:', error); - return 'Error loading schedules.'; + if (definition.provider === 'DeepSeek') { + return { + intelligence, + provider: definition.provider, + model, + languageModel: deepseek(model), + }; } -} -function dateOrNever(date: number | undefined) { - // - if (!date) return 'never'; + if (definition.provider === 'Moonshot') { + return { + intelligence, + provider: definition.provider, + model, + languageModel: moonshot(model), + }; + } - return new Date(date).toISOString(); + return { + intelligence, + provider: definition.provider, + model, + languageModel: openai(model), + }; } diff --git a/apps/meseeks/convex/magicRock.tsx b/apps/meseeks/convex/magicRock.tsx deleted file mode 100644 index faa9f4ea..00000000 --- a/apps/meseeks/convex/magicRock.tsx +++ /dev/null @@ -1,85 +0,0 @@ -// TODO: move back to AI SDK when possible -// We moved away from AI SDK's transcribe() after finding out it was forcing -// file type to audio/wav, breaking transcription. We spent too many hours on that already. - -import { z } from 'zod/v3'; -import { action } from 'lib/convex'; -import { env } from 'schemas/envSchema'; - -const dictionary = [ - 'Meseeks', // - 'DeepSeek', - 'Qwen', - 'GPT', -].join(','); - -const baseUrl = 'https://api.mistral.ai/v1'; -const model = 'voxtral-mini-latest'; - -const audioArrayBufferSchema = z.unknown().transform((value, ctx) => { - // - if (value instanceof ArrayBuffer) return value; - - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Expected an ArrayBuffer', - }); - - return z.NEVER; -}); - -const audioContentTypeSchema = z - .string() - .trim() - .max(100) - .regex(/^audio\/[a-z0-9.+-]+(?:;.*)?$/i) - .optional(); - -export const transcribe = action({ - args: { - audio: audioArrayBufferSchema, - contentType: audioContentTypeSchema, - }, - handler: async (ctx, { audio, contentType }) => { - // - const identity = await ctx.auth.getUserIdentity(); - if (!identity) throw new Error('Unauthorized'); - - const audioBlob = new Blob([audio], { type: contentType ?? 'audio/webm' }); - const file = new File([audioBlob], 'recording.webm', { type: audioBlob.type }); - - const formData = new FormData(); - formData.append('file', file); - formData.append('model', model); - formData.append('context_bias', dictionary); - formData.append('temperature', '0'); - - const response = await fetch(`${baseUrl}/audio/transcriptions`, { - method: 'POST', - headers: { - Authorization: `Bearer ${env.MISTRAL_API_KEY}`, - }, - body: formData, - }); - - if (!response.ok) { - const errorText = await response.text(); - console.error('Mistral transcription failed:', { status: response.status, error: errorText }); - throw new Error(`Transcription failed: ${response.status}`); - } - - const json = await response.json(); - const result = z.object({ text: z.string() }).parse(json); - const transcription = result.text.trim(); - - console.debug('Transcription result', { - model, - baseUrl, - characterCount: transcription.length, - contextBias: dictionary, - text: transcription, - }); - - return transcription; - }, -}); diff --git a/apps/meseeks/convex/migrations.ts b/apps/meseeks/convex/migrations.ts index d901b8f8..c7e2ce36 100644 --- a/apps/meseeks/convex/migrations.ts +++ b/apps/meseeks/convex/migrations.ts @@ -1,20 +1,20 @@ import { Migrations } from '@convex-dev/migrations'; -import { components, internal } from './_generated/api.js'; -import { DataModel } from './_generated/dataModel.js'; -import { enableSkill } from './skills.private'; +import { components } from './_generated/api.js'; +import type { DataModel } from './_generated/dataModel.js'; export const migrations = new Migrations(components.migrations); -// TODO: break down into files, stop removing them thats dumb +/* +pre-existing migration reference: + +import { internal } from './_generated/api.js'; +import { enableSkill } from './skills.private'; -// Migration to enable specific skills for all existing users export const enableMissingSkillsFour = migrations.define({ table: 'users', migrateOne: async (ctx, doc) => { - // const skillsToEnable = ['transcribeYouTube', 'describeYouTube', 'compose']; - // Enable each skill for this user for (const skillKey of skillsToEnable) { await enableSkill(ctx, { userId: doc._id, @@ -24,15 +24,21 @@ export const enableMissingSkillsFour = migrations.define({ console.info(`Enabled ${skillsToEnable.length} skills for user ${doc._id}`); - return doc; // return unchanged + return doc; }, }); -// Migration to backfill action_details with empty history array +export const runEnableMissingSkillsFour = migrations.runner(internal.migrations.enableMissingSkillsFour); +*/ + +/* +pre-existing migration reference: + +import { internal } from './_generated/api.js'; + export const backfillActionDetailsHistory = migrations.define({ table: 'action_details', migrateOne: async (_ctx, doc) => { - // Only add history field to soft skill documents if (doc.skillKind === 'soft') { if (!doc.llm?.history) { return { @@ -44,10 +50,10 @@ export const backfillActionDetailsHistory = migrations.define({ }; } } - return doc; // return unchanged for hard skills or if history already exists + + return doc; }, }); -// Runner function to execute the migration -export const runEnableMissingSkillsFour = migrations.runner(internal.migrations.enableMissingSkillsFour); export const runBackfillActionDetailsHistory = migrations.runner(internal.migrations.backfillActionDetailsHistory); +*/ diff --git a/apps/meseeks/convex/pages.private.ts b/apps/meseeks/convex/pages.private.ts new file mode 100644 index 00000000..816e79a1 --- /dev/null +++ b/apps/meseeks/convex/pages.private.ts @@ -0,0 +1,108 @@ +import type { Id } from './_generated/dataModel'; +import type { MutationCtx, QueryCtx } from './_generated/server'; + +export async function findPageByRoute( + ctx: QueryCtx | MutationCtx, + args: { + owner: Id<'users'> | 'isPro'; + root: Id<'files'>; + route: string; + }, +) { + // + return await ctx.db + .query('pages') + .withIndex('by_owner_root_route', (q) => + q + .eq('owner', args.owner) // + .eq('root', args.root) + .eq('route', normalizePageRoute(args.route)), + ) + .first(); +} + +export async function listPagesForRoot( + ctx: QueryCtx | MutationCtx, + args: { + owner: Id<'users'> | 'isPro'; + root: Id<'files'>; + }, +) { + // + const pages = await ctx.db + .query('pages') + .withIndex('by_owner_root_route', (q) => + q + .eq('owner', args.owner) // + .eq('root', args.root), + ) + .collect(); + + return pages.sort((left, right) => left.route.localeCompare(right.route)); +} + +export async function replaceCompiledPagesForRoot( + ctx: MutationCtx, + args: { + owner: Id<'users'>; + root: Id<'files'>; + action: Id<'actions'>; + pages: Array<{ + file: Id<'files'>; + route: string; + sourcePath: string; + sourceHash?: string; + diagnostics?: Array; + }>; + }, +) { + // + const existing = await listPagesForRoot(ctx, { + owner: args.owner, + root: args.root, + }); + const existingByRoute = new Map(existing.map((page) => [page.route, page])); + const nextRoutes = new Set(args.pages.map((page) => page.route)); + const compiledAt = Date.now(); + + for (const page of args.pages) { + const row = existingByRoute.get(page.route); + const patch = { + owner: args.owner, + root: args.root, + file: page.file, + route: page.route, + sourcePath: page.sourcePath, + sourceHash: page.sourceHash, + compiledBy: args.action, + compiledAt, + status: page.diagnostics?.length ? ('errored' as const) : ('enabled' as const), + diagnostics: page.diagnostics, + }; + + if (row) { + await ctx.db.patch(row._id, patch); + continue; + } + + await ctx.db.insert('pages', patch); + } + + for (const page of existing) { + if (nextRoutes.has(page.route)) continue; + + await ctx.db.patch(page._id, { + status: 'disabled', + compiledBy: args.action, + compiledAt, + }); + } +} + +function normalizePageRoute(route: string) { + // + const trimmed = route.trim(); + if (!trimmed || trimmed === '/') return '/'; + + return `/${trimmed.replace(/^\/+|\/+$/g, '')}`; +} diff --git a/apps/meseeks/convex/pages.ts b/apps/meseeks/convex/pages.ts new file mode 100644 index 00000000..35783641 --- /dev/null +++ b/apps/meseeks/convex/pages.ts @@ -0,0 +1,46 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import { query } from 'lib/convex'; +import { ensureScopeOwner } from './files.private'; +import { findPageByRoute, listPagesForRoot } from './pages.private'; +import { getCurrentUser } from './users.private'; + +export const listByRoot = query({ + args: { + root: zid('files'), + }, + handler: async (ctx, { root }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + const actionRoot = await ensureScopeOwner(ctx, { + owner: currentUser._id, + directory: root, + }); + + return await listPagesForRoot(ctx, { + owner: currentUser._id, + root: actionRoot._id, + }); + }, +}); + +export const findByRoute = query({ + args: { + root: zid('files'), + route: z.string().min(1), + }, + handler: async (ctx, { root, route }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + const actionRoot = await ensureScopeOwner(ctx, { + owner: currentUser._id, + directory: root, + }); + + return await findPageByRoute(ctx, { + owner: currentUser._id, + root: actionRoot._id, + route, + }); + }, +}); diff --git a/apps/meseeks/convex/polarEvents.private.ts b/apps/meseeks/convex/polarEvents.private.ts new file mode 100644 index 00000000..d152b69f --- /dev/null +++ b/apps/meseeks/convex/polarEvents.private.ts @@ -0,0 +1,16 @@ +import { z } from 'zod/v3'; +import { defineMutation } from 'lib/convex'; +import { polarEventReceiptSchema } from 'schemas/polarEventSchema'; + +export const recordPolarEvent = defineMutation({ + args: z.object({ + event: polarEventReceiptSchema.omit({ receivedAt: true }), + }), + handler: async (ctx, { event }) => { + // + return await ctx.db.insert('polar_events', { + ...event, + receivedAt: Date.now(), + }); + }, +}); diff --git a/apps/meseeks/convex/polarEvents.ts b/apps/meseeks/convex/polarEvents.ts new file mode 100644 index 00000000..01339746 --- /dev/null +++ b/apps/meseeks/convex/polarEvents.ts @@ -0,0 +1,8 @@ +import { internalMutation } from 'lib/convex'; +import { recordPolarEvent } from './polarEvents.private'; + +// called by the Polar webhook HTTP action to persist webhook receipts before branching on event type +export const _recordPolarEvent = internalMutation({ + args: recordPolarEvent.args.shape, + handler: recordPolarEvent, +}); diff --git a/apps/meseeks/convex/reactor.constants.ts b/apps/meseeks/convex/reactor.constants.ts deleted file mode 100644 index a57dde8f..00000000 --- a/apps/meseeks/convex/reactor.constants.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { env } from 'schemas/envSchema'; - -// convex actions hard-timeout after 10 minutes; keep reactor work below that so cleanup has runway -export const CONVEX_ACTION_TIMEOUT_MS = 600 * 1000; -export const ACTION_TIMEOUT_MS = CONVEX_ACTION_TIMEOUT_MS - env.ACTION_TIMEOUT_BUFFER_MS; diff --git a/apps/meseeks/convex/reactor.private.ts b/apps/meseeks/convex/reactor.private.ts index 82272250..7b88d5fa 100644 --- a/apps/meseeks/convex/reactor.private.ts +++ b/apps/meseeks/convex/reactor.private.ts @@ -1,424 +1,3 @@ -import { z } from 'zod/v3'; -import type { Doc, Id } from './_generated/dataModel'; -import type { ActionCtx, MutationCtx } from './_generated/server'; -import { internal } from './_generated/api'; -import { executeTool } from '@ai-sdk/provider-utils'; -import { isError, NOT_ENOUGH_BUDGET_ERROR, messageFrom, NotEnoughBudget } from 'lib/errors'; -import { asDollars } from 'lib/money'; -import { prepareContext, type MagicRockContext } from './magicRock.private'; -import { newActionSchema } from 'schemas/actionSchema'; -import type { AIToolResult } from 'schemas/toolSchema'; -import { env } from 'schemas/envSchema'; -import type { skillSchema } from 'schemas/skillSchema'; -import { builtInSkillSchema } from 'schemas/skillSchema'; -import { tokenSchema } from 'schemas/topUpSchema'; -import { estimateCostFor, extractSystemInstructions } from 'skills/createAITool'; -import { createReactions } from 'skills/createReactions'; -import { createTool } from 'skills/tools'; -import { ACTION_TIMEOUT_MS } from './reactor.constants'; - -export async function perform( - ctx: ActionCtx, - { - taskId, - actionId, - }: { - taskId: Id<'tasks'>; - actionId: Id<'actions'>; - }, -) { - // - console.debug(`Executing action ${actionId} for task ${taskId}`); - - const { task, action, skill } = await ctx.runQuery(internal.reactor._prepare, { - taskId, - actionId, - }); - - console.debug( - `Using skill ${skill.key} with ${Object.keys(action.args).length} args: ${Object.keys(action.args).join(', ')}`, - ); - - // create a timeout promise that rejects before convex's hard timeout - let timeoutId: ReturnType | undefined; - const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout(() => { - reject( - new Error( - `Action execution timed out after ${ACTION_TIMEOUT_MS / 1000} seconds to handle Convex timeout`, - ), - ); - }, ACTION_TIMEOUT_MS); - }); - - // wrap the entire try block execution with timeout - const executeWithTimeout = async () => { - // - // prepare context if needed - const context = skill.kind === 'soft' ? await prepareContext(ctx, task, action, skill) : undefined; - - // persist initial action details with request context - await persistInitialActionDetails(ctx, action, skill, context); - - // check budget - const expectedCost = await ensureWithinBudget(ctx, task, action, skill, context); - - console.debug(`Expected cost ${asDollars({ bigInt: expectedCost, precision: 6 })} energy.`); - - // if the action is not yet authorized, try auto-approving it - if (!action.approvedAt) { - // - const wasAutoApproved = await tryAutoApprove(ctx, task, action, skill, expectedCost); - - // if failed, request human approval - if (!wasAutoApproved) return await requestHumanApproval(ctx, { actionId, taskId }); - } - - const tool = createTool(ctx, task, action, skill, context); - const args = parseArgs(tool, action.args); - const execute = tool.execute; - if (!execute) throw new Error(`Tool execute is not available for action ${action._id}.`); - - const execution = executeTool({ - execute, - input: args, - options: { - toolCallId: String(action._id), - messages: [], - }, - }); - - let toolResult: AIToolResult | undefined; - for await (const part of execution) { - if (part.type === 'final') { - toolResult = part.output; - } - } - - if (!toolResult) throw new Error(`Tool execution returned no result for action ${action._id}.`); - - const { result, costs } = toolResult; - - await setFinished(ctx, { - actionId, - taskId, - result, - status: 'succeeded', - costs, - // TODO: also persist reactions - }); - }; - - try { - // - // race the entire execution against the timeout - await Promise.race([executeWithTimeout(), timeoutPromise]); - // - } catch (error) { - // - const result = await handleActionError({ actionId, action, error }); - - await setFinished(ctx, { - actionId, - taskId, - status: 'failed', - costs: [], - result: result ?? { text: 'Max auto-fix attempts reached.', reactions: [] }, - }); - // - } finally { - // - if (timeoutId) clearTimeout(timeoutId); - await runNextActionIfNeeded(ctx, { taskId }); - } -} - -function parseArgs(tool: ReturnType, args: unknown) { - // - // all our tools use Zod schemas, so inputSchema always has safeParse - const schema = tool.inputSchema; - - // type guard: check that schema is a Zod schema with safeParse - if (!('safeParse' in schema)) { - throw new Error('Expected Zod schema but got something else'); - } - - const parsedArgs = schema.safeParse(args); - - if (!parsedArgs.success) throw new Error(`Invalid skill args: ${parsedArgs.error.message}`); - - return parsedArgs.data; -} - -async function estimateAndPersistCost( - ctx: ActionCtx, - action: Doc<'actions'>, - task: Doc<'tasks'>, - skill: z.infer, - context?: MagicRockContext, -) { - if (action.estimatedCost) return action.estimatedCost; - - const estimatedCost = estimateCostFor(skill, task, action._id, context); - - console.debug( - `Setting estimated cost for ${action._id}: ${asDollars({ bigInt: estimatedCost, precision: 6 })} energy`, - ); - - await ctx.runMutation(internal.reactor._setEstimatedCost, { - actionId: action._id, - estimatedCost, - }); - - return estimatedCost; -} - -async function ensureWithinBudget( - ctx: ActionCtx, - task: Doc<'tasks'>, - action: Doc<'actions'>, - skill: z.infer, - context?: MagicRockContext, -) { - // - const estimatedCost = await estimateAndPersistCost(ctx, action, task, skill, context); - - if (estimatedCost > task.energyBudget.available) { - throw NotEnoughBudget( - `Not enough energy. Estimated cost: ${asDollars({ bigInt: estimatedCost })}.`, - action, - action.skillKey, - estimatedCost, - ); - } - - return estimatedCost; -} - -async function autoApprove( - ctx: ActionCtx, // - task: Doc<'tasks'>, - action: Doc<'actions'>, -) { - await ctx.runMutation(internal.action._authorize, { - taskId: task._id, - actionId: action._id, - approver: 'auto', - hasApproved: true, - }); - - return true; -} - -async function tryAutoApprove( - ctx: ActionCtx, - task: Doc<'tasks'>, - action: Doc<'actions'>, - skill: z.infer, - expectedCost: bigint, -) { - // auto approve if the author is the task owner - if (action.author === task.owner) return autoApprove(ctx, task, action); - - // reject if requires more budget - if (skill.preApprovedCost === 'none') return false; - if (skill.preApprovedCost < expectedCost) return false; - - // reject if too many consecutive actions are from Meseeks - if (expectedCost > 0n && (await hasReachedMaxConsecutiveCompanionActions(ctx, task))) { - // - console.debug( - `Skipping reacting for task ${task._id} because the last ${env.MAX_CONSECUTIVE_COMPANION_ACTIONS} actions are from Meseeks.`, - ); - - return false; - } - - return autoApprove(ctx, task, action); -} - -// ¡¡¡do not remove — this prevents machines from taking over!!! -async function hasReachedMaxConsecutiveCompanionActions( - ctx: ActionCtx, // - task: Doc<'tasks'>, -) { - // - const lastActions = await ctx.runQuery(internal.action._findLastActions, { - taskId: task._id, - amount: env.MAX_CONSECUTIVE_COMPANION_ACTIONS, - }); - - return lastActions.every((action: Doc<'actions'>) => action.author !== task.owner); -} - -async function requestHumanApproval( - ctx: ActionCtx, // - { - actionId, - taskId, - }: { - actionId: Id<'actions'>; - taskId: Id<'tasks'>; - }, -) { - // - await ctx.runMutation(internal.reactor._requestAuthorization, { actionId, taskId }); -} - -async function handleActionError({ - actionId, - action, - error, -}: { - actionId: Id<'actions'>; - action: Doc<'actions'>; - error: unknown; -}): Promise<{ - text: string; - reactions: Array>; -} | null> { - // - console.info(`action ${actionId} execution failed: ${error}`); - - const result: { - text: string; - reactions: Array>; - } = { - text: messageFrom(error), - reactions: [], - }; - - // react with appropriate reactions - if (isNotEnoughBudgetError(error)) { - // - console.debug(`Lacking energy for action ${actionId}. Requesting more energy.`); - - result.text = error.data.message; - result.reactions = createReactions(error.data.action, [ - { - skillKey: 'requestBudget', - args: { - estimatedCost: error.data.estimatedCost, - previousActionKey: error.data.previousActionKey, - }, - }, - ]); - // - } else { - // - // attempt auto-fix - result.reactions = createReactions(action, [ - { - skillKey: 'iterate', - args: {}, - }, - ]); - } - - return result; -} - -function isNotEnoughBudgetError(error: unknown): error is ReturnType { - // - return isError(NOT_ENOUGH_BUDGET_ERROR, error); -} - -async function setFinished( - ctx: ActionCtx, - args: { - actionId: Id<'actions'>; - taskId: Id<'tasks'>; - result: { - text?: string | undefined; - reactions: Array>; - }; - status: 'succeeded' | 'failed'; - costs: Array<{ - symbol: z.infer; - amount: bigint; - description: string; - }>; - }, -) { - return await ctx.runMutation(internal.reactor._finish, args); -} - -async function persistInitialActionDetails( - ctx: ActionCtx, - action: Doc<'actions'>, - skill: Doc<'skills'> | z.infer, - context?: MagicRockContext, -) { - try { - if (skill.kind === 'soft') { - // - if (!context) { - throw new Error('Context is required for soft skills during initial persistence'); - } - - const { model, provider } = (() => { - // - if (typeof context.model === 'string') { - const [provider, model] = context.model.split('/'); - return { model, provider }; - } - - return { - model: context.model.modelId, - provider: context.model.provider, - }; - })(); - - await ctx.runMutation(internal.action.details._persist, { - details: { - actionId: action._id, - skillKind: 'soft', - skillKey: skill.key, - skillDescription: skill.description, - llm: { - model, - provider, - temperature: context.temperature || 0.7, - maxTokens: context.maxOutputTokens, // TODO: rename on DB - systemInstructions: extractSystemInstructions(context.system), - historyLength: Array.isArray(context.messages) ? context.messages.length : 0, - history: Array.isArray(context.messages) - ? context.messages - .filter((msg) => msg.role !== 'system') - .map((msg) => ({ - role: msg.role, - content: - typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content), - })) - : [], - availableTools: context.tools ? Object.keys(context.tools) : [], - }, - }, - }); - } else if (skill.kind === 'hard') { - // - await ctx.runMutation(internal.action.details._persist, { - details: { - actionId: action._id, - skillKind: 'hard', - skillKey: skill.key, - skillDescription: skill.description, - http: { - method: skill.config.method, - url: skill.config.url, - }, - }, - }); - } - } catch (error) { - // If persistence fails, log but don't fail the action - console.warn(`Failed to persist initial action details for ${action._id}:`, error); - } -} - -export async function runNextActionIfNeeded( - ctx: ActionCtx | MutationCtx, // - { taskId }: { taskId: Id<'tasks'> }, -) { - return await ctx.runMutation(internal.reactor._claimAndScheduleNext, { taskId }); -} +export { claimNextAction } from './reactor/claim.private'; +export { loadForPerform, perform } from './reactor/perform.private'; +export { settleAction } from './reactor/settle.private'; diff --git a/apps/meseeks/convex/reactor.ts b/apps/meseeks/convex/reactor.ts index 0edbf2ae..8490ae58 100644 --- a/apps/meseeks/convex/reactor.ts +++ b/apps/meseeks/convex/reactor.ts @@ -1,251 +1,52 @@ +import { internalAction, internalMutation, mutation } from 'lib/convex'; import { zid } from 'convex-helpers/server/zod3'; -import { z } from 'zod/v3'; -import type { Doc, Id } from './_generated/dataModel'; -import { internal } from './_generated/api'; -import { internalAction, internalMutation, internalQuery } from 'lib/convex'; -import { NotFound } from 'lib/errors'; -import { asDollars } from 'lib/money'; -import { newActionSchema, resolvedActionStatusSchema } from 'schemas/actionSchema'; -import { builtInSkillSchema } from 'schemas/skillSchema'; -import { tokenSchema } from 'schemas/topUpSchema'; -import { - addActions, - findAction, - findNextAction, - findPendingAuthorizationAction, - findRunningAction, -} from './action.private'; -import { findSkill } from './skills.private'; -import { findTask, setTaskStatus, spendTaskFunds } from './tasks.private'; -import { perform } from './reactor.private'; +import { performArgsSchema } from 'schemas/reactorSchema'; +import { ensureScopeOwner } from './files.private'; +import { getCurrentUser } from './users.private'; +import { claimNextAction, loadForPerform, perform, settleAction } from './reactor.private'; -// scheduled by _claimAndScheduleNext to execute one claimed action end-to-end -export const _perform = internalAction({ - args: { - taskId: zid('tasks'), - actionId: zid('actions'), - }, - returns: z.null(), - // TODO: since we dropped support for sync actions, we could use ActionCtx only, and remove MutationCtx from the arg type - handler: perform, -}); - -// called by reactor runtime to load the task, action, and resolved skill before execution -export const _prepare = internalQuery({ +export const claimNext = mutation({ args: { - taskId: zid('tasks'), - actionId: zid('actions'), + root: zid('files'), }, - handler: async ( - ctx, - { taskId, actionId }, - ): Promise<{ - task: Doc<'tasks'>; - action: Doc<'actions'>; - skill: Doc<'skills'> | z.infer; - }> => { + handler: async (ctx, { root }) => { // - const [task, action] = await Promise.all([ - findTask(ctx, { taskId }), // - findAction(ctx, { actionId }), - ]); - - const skill = await findSkill(ctx, { - key: action.skillKey, - owner: task.owner, + const currentUser = await getCurrentUser(ctx, {}); + const actionRoot = await ensureScopeOwner(ctx, { + owner: currentUser._id, + directory: root, }); - return { task, action, skill }; - }, -}); - -// called after action creation, authorization, or finish to atomically reserve the next execution slot -export const _claimAndScheduleNext = internalMutation({ - args: { - taskId: zid('tasks'), - }, - returns: z - .object({ - actionId: zid('actions'), - scheduledFunctionId: zid('_scheduled_functions'), - }) - .nullable(), - handler: async (ctx, { taskId }) => { - // - const skip = (message: string) => { - // - console.info(message); - return null; - }; - - const runningAction = await findRunningAction(ctx, { taskId }); - if (runningAction) - return skip( - `Skipping next action for task ${taskId} because there is a running action (${runningAction.skillKey}, ${runningAction._id}).`, - ); - - const pendingAuthorization = await findPendingAuthorizationAction(ctx, { taskId }); - if (pendingAuthorization) - return skip( - `Skipping next action for task ${taskId} because there is a pending authorization action (${pendingAuthorization.skillKey}, ${pendingAuthorization._id}).`, - ); - - const nextAction = await findNextAction(ctx, { taskId }); - if (!nextAction) - return skip(`Skipping next action for task ${taskId} because there are no more pending actions.`); - - // generated function references form the reactor loop, so pin the scheduler result at the boundary - const scheduledFunctionId: Id<'_scheduled_functions'> = await ctx.scheduler.runAfter( - 0, - internal.reactor._perform, - { - taskId, - actionId: nextAction._id, - }, - ); - - console.debug(`${nextAction._id} starts as scheduled function ${scheduledFunctionId}`); - - await ctx.db.patch(nextAction._id, { - status: 'running', - startedAt: Date.now(), - scheduledFunctionId, + return await claimNextAction(ctx, { + owner: currentUser._id, + root: actionRoot._id, }); - await setTaskStatus(ctx, { taskId, newStatus: 'acting' }); // if any running action, task is 'acting' - - return { - actionId: nextAction._id, - scheduledFunctionId, - }; }, }); -// called by reactor runtime so estimated cost is stored once and reused -export const _setEstimatedCost = internalMutation({ +// scheduled by root bootstrap after it enqueues the initial seed action; bootstrap lives in files, but seed must enter the normal Reactor claim path. +export const _claimNext = internalMutation({ args: { - actionId: zid('actions'), - estimatedCost: z.bigint(), - }, - handler: async (ctx, { actionId, estimatedCost }) => { - return await ctx.db.patch(actionId, { estimatedCost }); + owner: zid('users'), + root: zid('files'), }, + handler: claimNextAction, }); -// called by reactor runtime when auto-approval fails and user input is required -export const _requestAuthorization = internalMutation({ - args: { - actionId: zid('actions'), - taskId: zid('tasks'), - }, - handler: async (ctx, { actionId, taskId }) => { - // - console.debug(`requesting authorization for ${actionId}`); - - await ctx.db.patch(actionId, { status: 'pending authorization' }); - await setTaskStatus(ctx, { taskId, newStatus: 'blocked' }); // if any pending authorization action, task is 'blocked' - }, +// called by _perform as its first Convex crossing to load a claimed action and move it to running. +export const _loadForPerform = internalMutation({ + args: loadForPerform.args.shape, + handler: loadForPerform, }); -// called by reactor runtime to persist final result/costs and enqueue reaction actions -export const _finish = internalMutation({ - args: { - actionId: zid('actions'), - taskId: zid('tasks'), - result: z.object({ - text: z.string().optional(), - reactions: z.array(newActionSchema), - }), - status: resolvedActionStatusSchema.exclude(['skipped']), - costs: z.array( - z.object({ - symbol: tokenSchema, - amount: z.bigint(), - description: z.string(), - }), - ), - }, - handler: async (ctx, { actionId, taskId, result, status, costs }) => { - // - console.debug(`${actionId} finished with ${status}`); - - const action = await ctx.db.get(actionId); - if (!action) throw NotFound(); - - if (action.status === 'skipped') { - // - console.info( - 'INTERRUPTION', - `_finish(${actionId}, ${status}) result was ignored because status === 'skipped'. This should only happen as a consequence of an user stop() action.`, - `skill key: ${action.skillKey}`, - ); - - // TODO: keep track of interruption costs - - return; - } - - if (action.result) { - throw new Error(`Action result already set for ${actionId}. Trying to set to ${JSON.stringify(result)}`); - } - - const resultToStore = addFailureContextIfNeeded({ action, result, status }); - - const totalCost = costs.reduce((acc, cost) => acc + cost.amount, 0n); - - console.debug( - `Finished as ${status} with ${resultToStore.text?.length} characters. Total cost: ${asDollars({ bigInt: totalCost, precision: 6 })}`, - ); - - if (status === 'succeeded' && totalCost > 0) { - await spendTaskFunds(ctx, { taskId: action.taskId, amount: totalCost }); - } - - await ctx.db.patch(actionId, { result: resultToStore, status, costs }); - - const task = await ctx.db.get(taskId); - - if (task?.isActive) { - // - await setTaskStatus(ctx, { taskId, newStatus: 'unread' }); - - // schedule all reactions - await addActions(ctx, { - taskId, - owner: task.owner, - author: action._id, - depth: action.depth + 1, - skills: resultToStore.reactions.map((reaction) => ({ - skillKey: reaction.skillKey, - args: reaction.args, - })), - }); - } - }, +// called by perform as its final Convex crossing so canonical apply and action resolution happen atomically. +export const _settle = internalMutation({ + args: settleAction.args.shape, + handler: settleAction, }); -function addFailureContextIfNeeded({ - action, - result, - status, -}: { - action: Doc<'actions'>; - result: { - text?: string | undefined; - reactions: Array>; - }; - status: 'succeeded' | 'failed'; -}) { - // - if (status !== 'failed') return result; - if (result.text?.trim()) return result; - - return { - ...result, - text: [ - `Action ${action._id} (${action.skillKey}) failed without an error message.`, - `Task: ${action.taskId}.`, - `Previous status: ${action.status}.`, - `Age: ${Math.round((Date.now() - action._creationTime) / 1000)} seconds.`, - ].join(' '), - }; -} +// scheduled by claimNextAction because provider calls and Object Storage staging cannot run inside a mutation. +export const _perform = internalAction({ + args: performArgsSchema.shape, + handler: perform, +}); diff --git a/apps/meseeks/convex/reactor/apply.private.ts b/apps/meseeks/convex/reactor/apply.private.ts new file mode 100644 index 00000000..94288fce --- /dev/null +++ b/apps/meseeks/convex/reactor/apply.private.ts @@ -0,0 +1,257 @@ +import type { Id } from 'convex/_generated/dataModel'; +import type { MutationCtx } from 'convex/_generated/server'; +import { + compileMutationSchema, + fileMutationSchema, + triggerMutationSchema, + type CompileMutation, + type FileMutation, + type StagedText, + type TriggerMutation, +} from 'schemas/reactorSchema'; +import { + buildPath, + createDirectoryForAction, + createStoredFileForAction, + createTextFileAtPathForAction, + createTextFileForAction, + moveFileForAction, + tagFileForAction, + untagFileForAction, + writeActionOutputFile, + writeTextFileForAction, +} from '../files.private'; +import { replaceCompiledPagesForRoot } from '../pages.private'; +import { replaceCompiledSkillsForRoot } from '../skills.private'; +import { disableTriggerForAction } from '../triggers.private'; +import { replaceCompiledMutationTriggersForRoot } from '../triggers.private'; + +export async function applyActionOutputFile( + ctx: MutationCtx, + args: { + owner: Id<'users'>; + action: Id<'actions'>; + root: Id<'files'>; + index: number; + body: StagedText; + }, +) { + // + const file = await writeActionOutputFile(ctx, { + owner: args.owner, + action: args.action, + root: args.root, + index: args.index, + content: args.body.content, + contentType: args.body.contentType, + hash: args.body.hash, + size: args.body.size, + storageKey: args.body.storageKey, + }); + + return { + file, + path: await importPath(ctx, file._id), + }; +} + +export async function applyFileMutation( + ctx: MutationCtx, + args: { + owner: Id<'users'>; + action: Id<'actions'>; + mutation: FileMutation; + }, +) { + // + const mutation = fileMutationSchema.parse(args.mutation); + + if (mutation.kind === 'createDirectory') { + const file = await createDirectoryForAction(ctx, { + owner: args.owner, + action: args.action, + parent: mutation.parent, + name: mutation.name, + }); + + return { + revision: file.currentRevision, + path: await importPath(ctx, file._id), + }; + } + + if (mutation.kind === 'createFile') { + const file = await createStoredFileForAction(ctx, { + owner: args.owner, + action: args.action, + parent: mutation.parent, + name: mutation.name, + contentType: mutation.body.contentType, + hash: mutation.body.hash, + size: mutation.body.size, + storageKey: mutation.body.storageKey, + }); + + return { + revision: file.currentRevision, + path: await importPath(ctx, file._id), + }; + } + + if (mutation.kind === 'createText') { + const file = await createTextFileForAction(ctx, { + owner: args.owner, + action: args.action, + parent: mutation.parent, + name: mutation.name, + content: mutation.body.content, + contentType: mutation.body.contentType, + hash: mutation.body.hash, + size: mutation.body.size, + storageKey: mutation.body.storageKey, + }); + + return { + revision: file.currentRevision, + path: await importPath(ctx, file._id), + }; + } + + if (mutation.kind === 'createTextAtPath') { + const created = await createTextFileAtPathForAction(ctx, { + owner: args.owner, + action: args.action, + parent: mutation.parent, + path: mutation.path, + content: mutation.body.content, + contentType: mutation.body.contentType, + hash: mutation.body.hash, + size: mutation.body.size, + storageKey: mutation.body.storageKey, + }); + + return { + revisions: created.revisions, + path: await importPath(ctx, created.file._id), + }; + } + + if (mutation.kind === 'writeText') { + const revision = await writeTextFileForAction(ctx, { + owner: args.owner, + action: args.action, + file: mutation.file, + beforeContent: mutation.beforeContent, + content: mutation.body.content, + contentType: mutation.body.contentType, + hash: mutation.body.hash, + size: mutation.body.size, + storageKey: mutation.body.storageKey, + expectedRevision: mutation.expectedRevision, + }); + + return { + revision, + path: await importPath(ctx, mutation.file), + }; + } + + if (mutation.kind === 'move') { + const revision = await moveFileForAction(ctx, { + owner: args.owner, + action: args.action, + file: mutation.file, + parent: mutation.parent, + name: mutation.name, + }); + + return { + revision, + path: await importPath(ctx, mutation.file), + }; + } + + if (mutation.kind === 'tag') { + const revision = await tagFileForAction(ctx, { + owner: args.owner, + action: args.action, + file: mutation.file, + key: mutation.key, + value: mutation.value, + }); + + return { + revision, + path: await importPath(ctx, mutation.file), + }; + } + + const revision = await untagFileForAction(ctx, { + owner: args.owner, + action: args.action, + file: mutation.file, + key: mutation.key, + }); + + return { + revision, + path: await importPath(ctx, mutation.file), + }; +} + +export async function applyTriggerMutation( + ctx: MutationCtx, + args: { + owner: Id<'users'>; + root: Id<'files'>; + action: Id<'actions'>; + mutation: TriggerMutation; + }, +) { + // + const mutation = triggerMutationSchema.parse(args.mutation); + + return await disableTriggerForAction(ctx, { + owner: args.owner, + trigger: mutation.trigger, + }); +} + +export async function applyCompileMutation( + ctx: MutationCtx, + args: { + owner: Id<'users'>; + root: Id<'files'>; + action: Id<'actions'>; + mutation: CompileMutation; + }, +) { + // + const mutation = compileMutationSchema.parse(args.mutation); + + await replaceCompiledSkillsForRoot(ctx, { + owner: args.owner, + root: args.root, + action: args.action, + skills: mutation.skills, + }); + await replaceCompiledPagesForRoot(ctx, { + owner: args.owner, + root: args.root, + action: args.action, + pages: mutation.pages, + }); + await replaceCompiledMutationTriggersForRoot(ctx, { + owner: args.owner, + root: args.root, + action: args.action, + triggers: mutation.triggers, + }); + + return mutation.diagnostics; +} + +async function importPath(ctx: MutationCtx, file: Id<'files'>) { + // + return await buildPath(ctx, { file }); +} diff --git a/apps/meseeks/convex/reactor/claim.private.ts b/apps/meseeks/convex/reactor/claim.private.ts new file mode 100644 index 00000000..eed08013 --- /dev/null +++ b/apps/meseeks/convex/reactor/claim.private.ts @@ -0,0 +1,388 @@ +import { z } from 'zod/v3'; +import type { Doc, Id } from 'convex/_generated/dataModel'; +import type { MutationCtx } from 'convex/_generated/server'; +import { internal } from 'convex/_generated/api'; +import { preparationActionDetailSchema } from 'schemas/actionDetailSchema'; +import { intelligenceKeys } from 'schemas/intelligenceSchema'; +import { + configuredSkillKindSchema, + decisionConfigSchema, + executeConfigSchema, + httpConfigSchema, +} from 'schemas/skillSchema'; +import { claimedSkillSchema, type ClaimedSkill } from 'schemas/reactorSchema'; +import { isError, NOT_FOUND_ERROR } from 'lib/errors'; +import { stringToZod } from 'lib/zodToString'; +import { recordActionDetail, recordActionPreparation } from '../action/details.private'; +import { prepareCompile } from '../compile.private'; +import { commitUploadInputSchema, prepareCommitUpload } from '../instincts/commitUpload.private'; +import { executeInputSchema } from '../instincts/execute.private'; +import { prepareExecute } from '../execute.private'; +import { findInstinct } from '../instincts/index.private'; +import { prepareUpload, prepareUploadInputSchema } from '../instincts/prepareUpload.private'; +import { thinkInputSchema } from '../instincts/think.private'; +import { prepareWrite, writeInputSchema } from '../instincts/write.private'; +import { prepareMagicRock } from '../magicRock.private'; +import { prepareRequest, requestPreparationSchema } from '../request.private'; +import { ensureInstinctSkillRows, findSkillSafe } from '../skills.private'; + +type PreparedClaim = + | { + kind: 'claimable'; + input: Record; + preparation: z.infer; + skill: ClaimedSkill; + } + | { + kind: 'unclaimable'; + reason: string; + }; + +export async function claimNextAction(ctx: MutationCtx, { owner, root }: { owner: Id<'users'>; root: Id<'files'> }) { + // + const actions = await ctx.db + .query('actions') + .withIndex('by_root_status', (q) => + q + .eq('root', root) // + .eq('status', 'enqueued'), + ) + .order('asc') + .collect(); + for (const action of actions) { + if (action.owner !== owner) continue; + if (action.claimedAt) continue; + + const prepared = await prepareClaim(ctx, { + owner, + action, + }); + if (prepared.kind === 'unclaimable') { + await skipUnclaimableAction(ctx, { + action, + reason: prepared.reason, + }); + continue; + } + + await recordActionPreparation(ctx, { detail: prepared.preparation }); + + const scheduledFunctionId = await ctx.scheduler.runAfter(0, internal.reactor._perform, { + action: action._id, + }); + const claimedAt = Date.now(); + + await ctx.db.patch(action._id, { + claimedAt, + scheduledFunctionId, + }); + + return action._id; + } + + return undefined; +} + +export async function resolveCallableSkill( + ctx: MutationCtx, + { owner, root, key }: { owner: Id<'users'>; root: Id<'files'>; key: string }, +): Promise { + // + await ensureInstinctSkillRows(ctx, {}); + + const skill = await findSkillSafe(ctx, { owner, root, key }); + if (!skill) throw new Error(`Unknown skill: ${key}`); + + return claimedSkillSchema.parse({ + source: skill.source, + key: skill.key, + kind: skill.kind, + config: skill.config ?? {}, + inputSchema: skill.inputSchema, + outputSchema: skill.outputSchema, + }); +} + +export function validateSkillInput(skill: ClaimedSkill, input: Record): Record { + // + if (skill.source !== 'instinct') return z.record(z.unknown()).parse(stringToZod(skill.inputSchema).parse(input)); + + const instinct = findInstinct(skill.key); + if (!instinct) throw new Error(`Unknown instinct: ${skill.key}`); + return z.record(z.unknown()).parse(instinct.inputSchema.parse(input)); +} + +export function findActionIntelligence( + input: Record, + intelligence?: z.infer, +) { + // + if (intelligence) return intelligence; + if (typeof input.intelligence !== 'string') return undefined; + + const parsed = intelligenceKeys.safeParse(input.intelligence); + if (!parsed.success) return undefined; + + return parsed.data; +} + +async function prepareClaim( + ctx: MutationCtx, + { owner, action }: { owner: Id<'users'>; action: Doc<'actions'> }, +): Promise { + // + try { + const skill = await findClaimableSkill(ctx, { + owner, + root: action.root, + key: action.skill, + }); + if (!skill) { + return { + kind: 'unclaimable', + reason: `Unknown skill: ${action.skill}`, + }; + } + + const input = validateSkillInput(skill, action.input); + const preparation = await prepareAction(ctx, { + action, + input, + skill, + }); + + return { + kind: 'claimable', + input, + preparation, + skill, + }; + } catch (error) { + const reason = claimabilityFailureReason(error); + if (!reason) throw error; + + return { + kind: 'unclaimable', + reason, + }; + } +} + +async function findClaimableSkill( + ctx: MutationCtx, + { owner, root, key }: { owner: Id<'users'>; root: Id<'files'>; key: string }, +) { + // + await ensureInstinctSkillRows(ctx, {}); + + const skill = await findSkillSafe(ctx, { owner, root, key }); + if (!skill) return undefined; + + return claimedSkillSchema.parse({ + source: skill.source, + key: skill.key, + kind: skill.kind, + config: skill.config ?? {}, + inputSchema: skill.inputSchema, + outputSchema: skill.outputSchema, + }); +} + +async function skipUnclaimableAction(ctx: MutationCtx, { action, reason }: { action: Doc<'actions'>; reason: string }) { + // + const now = Date.now(); + await recordActionDetail(ctx, { + detail: { + owner: action.owner, + action: action._id, + createdAt: now, + kind: 'error', + code: 'unclaimable', + message: reason, + }, + }); + + await ctx.db.patch(action._id, { + status: 'skipped', + finishedAt: now, + warnings: (action.warnings ?? []).concat(reason), + }); +} + +function claimabilityFailureReason(error: unknown) { + // + if (error instanceof z.ZodError) return `Invalid claim preparation: ${error.message}`; + if (isError(NOT_FOUND_ERROR, error)) return 'Claim preparation referenced a missing resource.'; + if (error instanceof Error && error.message === 'File changed before write.') return error.message; + + return undefined; +} + +async function prepareAction( + ctx: MutationCtx, + args: { + action: Doc<'actions'>; + input: Record; + skill: ClaimedSkill; + }, +) { + // + if (args.skill.source !== 'instinct') { + return prepareStoredSkill({ + action: args.action, + input: args.input, + skill: args.skill, + }); + } + + if (args.skill.key === 'think') { + const input = thinkInputSchema.parse(args.input); + const preparation = prepareMagicRock({ + intelligence: input.intelligence ?? args.action.intelligence, + prompt: input.prompt, + }); + + return preparationActionDetailSchema.parse({ + ...basePreparation(args.action), + skillKind: 'think', + ...preparation, + }); + } + + if (args.skill.key === 'request') { + const preparation = prepareRequest(requestPreparationSchema.parse(args.input)); + + return preparationActionDetailSchema.parse({ + ...basePreparation(args.action), + skillKind: 'request', + ...preparation, + }); + } + + if (args.skill.key === 'execute') { + const input = executeInputSchema.parse(args.input); + const preparation = prepareExecute({ + root: args.action.root, + ...input, + }); + + return preparationActionDetailSchema.parse({ + ...basePreparation(args.action), + skillKind: 'execute', + ...preparation, + }); + } + + if (args.skill.key === 'write') { + return preparationActionDetailSchema.parse( + await prepareWrite(ctx, { + action: args.action, + input: writeInputSchema.parse(args.input), + }), + ); + } + + if (args.skill.key === 'prepareUpload') { + return preparationActionDetailSchema.parse( + await prepareUpload(ctx, { + action: args.action, + input: prepareUploadInputSchema.parse(args.input), + }), + ); + } + + if (args.skill.key === 'commitUpload') { + return preparationActionDetailSchema.parse( + await prepareCommitUpload(ctx, { + action: args.action, + input: commitUploadInputSchema.parse(args.input), + }), + ); + } + + if (args.skill.key === 'compile') { + return await prepareCompile(ctx, { action: args.action }); + } + + return preparationActionDetailSchema.parse({ + ...basePreparation(args.action), + skillKind: 'instinct', + }); +} + +function prepareStoredSkill(args: { action: Doc<'actions'>; input: Record; skill: ClaimedSkill }) { + // + const kind = configuredSkillKindSchema.parse(args.skill.kind); + const config = args.skill.config ?? {}; + + if (kind === 'think') { + const parsedConfig = decisionConfigSchema.parse(config); + const prompt = textFromInput(args.input, 'prompt') ?? parsedConfig.instructions; + const intelligence = parsedConfig.model === 'auto' ? args.action.intelligence : parsedConfig.model; + const preparation = prepareMagicRock({ + intelligence, + prompt, + }); + + return preparationActionDetailSchema.parse({ + ...basePreparation(args.action), + skillKind: 'think', + ...preparation, + }); + } + + if (kind === 'request') { + const parsedConfig = httpConfigSchema.parse(config); + const preparation = prepareRequest({ + url: parsedConfig.url, + method: parsedConfig.method, + headers: parsedConfig.headers, + body: args.input.body, + }); + + return preparationActionDetailSchema.parse({ + ...basePreparation(args.action), + skillKind: 'request', + ...preparation, + }); + } + + const parsedConfig = executeConfigSchema.parse(config); + const inputLanguage = textFromInput(args.input, 'language'); + const language = parsedConfig.language ?? 'javascript'; + const requestedLanguage = inputLanguage === 'javascript' || inputLanguage === 'python' ? inputLanguage : language; + const preparation = prepareExecute({ + root: args.action.root, + code: String(args.input.code ?? ''), + language: requestedLanguage, + timeoutSeconds: parsedConfig.timeoutSeconds, + }); + + return preparationActionDetailSchema.parse({ + ...basePreparation(args.action), + skillKind: 'execute', + ...preparation, + }); +} + +function basePreparation(action: Doc<'actions'>) { + // + const now = Date.now(); + + return { + owner: action.owner, + action: action._id, + createdAt: now, + kind: 'preparation' as const, + skill: action.skill, + preparedAt: now, + }; +} + +function textFromInput(input: Record, key: string) { + // + const value = input[key]; + if (typeof value !== 'string') return undefined; + + return value; +} diff --git a/apps/meseeks/convex/reactor/perform.private.ts b/apps/meseeks/convex/reactor/perform.private.ts new file mode 100644 index 00000000..993dd8af --- /dev/null +++ b/apps/meseeks/convex/reactor/perform.private.ts @@ -0,0 +1,273 @@ +import { z } from 'zod/v3'; +import type { ActionCtx } from 'convex/_generated/server'; +import { internal } from 'convex/_generated/api'; +import { preparationActionDetailSchema } from 'schemas/actionDetailSchema'; +import { type ClaimedAction, performArgsSchema, type PerformResult } from 'schemas/reactorSchema'; +import { defineMutation } from 'lib/convex'; +import { stringToZod } from 'lib/zodToString'; +import { findActionPreparation } from '../action/details.private'; +import { findAction } from '../actions.private'; +import { executePreparationSchema, performExecute } from '../execute.private'; +import { findInstinct } from '../instincts/index.private'; +import { askMagicRock, magicRockPreparationSchema } from '../magicRock.private'; +import { performRequest, requestPreparationSchema } from '../request.private'; +import { cleanupStaged, stageText } from './stage.private'; +import { resolveCallableSkill, validateSkillInput } from './claim.private'; + +export const loadForPerform = defineMutation({ + args: performArgsSchema, + handler: async (ctx, { action }): Promise => { + // + const row = await findAction(ctx, { action }); + if (row.status !== 'enqueued') throw new Error('Action is not enqueued.'); + if (!row.claimedAt) throw new Error('Action is not claimed.'); + if (!row.scheduledFunctionId) throw new Error('Action is not scheduled.'); + if (row.startedAt) throw new Error('Action already started.'); + + const skill = await resolveCallableSkill(ctx, { + owner: row.owner, + root: row.root, + key: row.skill, + }); + const input = validateSkillInput(skill, row.input); + const preparation = await findActionPreparation(ctx, { action: row._id }); + const now = Date.now(); + + await ctx.db.patch(row._id, { + status: 'running', + startedAt: now, + }); + + return { + action: { + ...row, + status: 'running', + startedAt: now, + }, + input, + preparation, + skill, + warnings: preparation.warnings ?? [], + }; + }, +}); + +export async function perform(ctx: ActionCtx, args: z.infer) { + // + let claimed: ClaimedAction | undefined; + + try { + claimed = await ctx.runMutation(internal.reactor._loadForPerform, args); + const result = await performAction(claimed); + await settle(ctx, result); + + return result; + } catch (error) { + if (!claimed) throw error; + + const message = errorMessage(error); + await settle(ctx, { + action: claimed.action._id, + status: 'failed', + error: message, + warnings: claimed.warnings, + }); + + throw error; + } +} + +function preparationContext(preparation: z.infer) { + // + if (preparation.skillKind === 'instinct') return preparation.context; + if (preparation.skillKind === 'think') { + return { + intelligence: preparation.intelligence, + provider: preparation.provider, + model: preparation.model, + system: preparation.system, + prompt: preparation.prompt, + estimated: preparation.estimated, + warnings: preparation.warnings, + }; + } + if (preparation.skillKind === 'request') { + return { + url: preparation.url, + method: preparation.method, + headers: preparation.headers, + body: preparation.body, + timeoutMs: preparation.timeoutMs, + warnings: preparation.warnings, + }; + } + + return { + root: preparation.root, + code: preparation.code, + language: preparation.language, + timeoutSeconds: preparation.timeoutSeconds, + warnings: preparation.warnings, + }; +} + +async function performAction(claimed: ClaimedAction): Promise { + // + if (claimed.skill.source !== 'instinct') return await performStoredSkill(claimed); + + return await performInstinct(claimed); +} + +async function performInstinct(claimed: ClaimedAction): Promise { + // + const instinct = findInstinct(claimed.skill.key); + if (!instinct) return await skipped(claimed, `The ${claimed.action.skill} skill is not implemented yet.`); + + const result = await instinct.perform( + { + action: claimed.action, + input: claimed.input, + preparation: preparationContext(claimed.preparation), + warnings: claimed.warnings, + }, + { stageText }, + ); + + return validateSkillOutput(claimed, result); +} + +async function performStoredSkill(claimed: ClaimedAction): Promise { + // + if (claimed.skill.source === 'instinct') throw new Error('Expected stored skill.'); + + if (claimed.skill.kind === 'request') return await performConfiguredRequest(claimed); + if (claimed.skill.kind === 'think') return await performConfiguredThink(claimed); + + return await performConfiguredExecute(claimed); +} + +async function performConfiguredThink(claimed: ClaimedAction): Promise { + // + if (claimed.skill.source === 'instinct') throw new Error('Expected stored skill.'); + + const magicRock = magicRockPreparationSchema.parse(preparationContext(claimed.preparation)); + const result = await askMagicRock(magicRock); + const output = await stageText({ + owner: claimed.action.owner, + content: result.text, + contentType: 'text/mdx; charset=utf-8', + }); + + return { + action: claimed.action._id, + status: 'succeeded', + output, + providerReceipt: { + provider: result.provider, + model: result.model, + request: { + prompt: magicRock.prompt, + system: magicRock.system, + }, + response: { outputLength: result.text.length }, + usage: result.usage, + }, + warnings: claimed.warnings.concat(result.warnings), + }; +} + +async function performConfiguredRequest(claimed: ClaimedAction): Promise { + // + if (claimed.skill.source === 'instinct') throw new Error('Expected stored skill.'); + + return await performRequest({ + action: claimed.action, + preparation: requestPreparationSchema.parse(preparationContext(claimed.preparation)), + stageText, + warnings: claimed.warnings, + }); +} + +async function performConfiguredExecute(claimed: ClaimedAction): Promise { + // + if (claimed.skill.source === 'instinct') throw new Error('Expected stored skill.'); + + return await performExecute({ + action: claimed.action, + preparation: executePreparationSchema.parse(preparationContext(claimed.preparation)), + stageText, + warnings: claimed.warnings, + }); +} + +async function skipped(claimed: ClaimedAction, reason: string): Promise { + // + const output = await stageText({ + owner: claimed.action.owner, + content: reason, + contentType: 'text/mdx; charset=utf-8', + }); + + return { + action: claimed.action._id, + status: 'skipped', + output, + warnings: claimed.warnings.concat(reason), + }; +} + +function validateSkillOutput(claimed: ClaimedAction, result: PerformResult): PerformResult { + // + if (claimed.skill.source !== 'instinct') { + const validation = stringToZod(claimed.skill.outputSchema).safeParse(outputForValidation(result)); + if (validation.success) return result; + + return { + ...result, + warnings: (result.warnings ?? []).concat(`Skill output did not match schema: ${validation.error.message}`), + }; + } + + const instinct = findInstinct(claimed.skill.key); + if (!instinct) return result; + + const output = outputForValidation(result); + const validation = instinct.outputSchema.safeParse(output); + if (validation.success) return result; + + return { + ...result, + warnings: (result.warnings ?? []).concat(`Skill output did not match schema: ${validation.error.message}`), + }; +} + +function outputForValidation(result: PerformResult) { + // + if (!result.output) { + return { + summary: result.status, + }; + } + + return { + content: result.output.content, + contentType: result.output.contentType, + }; +} + +async function settle(ctx: ActionCtx, result: PerformResult) { + // + try { + await ctx.runMutation(internal.reactor._settle, result); + } catch (error) { + await cleanupStaged(result); + throw error; + } +} + +function errorMessage(error: unknown) { + // + if (error instanceof Error) return error.message; + + return String(error); +} diff --git a/apps/meseeks/convex/reactor/settle.private.ts b/apps/meseeks/convex/reactor/settle.private.ts new file mode 100644 index 00000000..ac2caa10 --- /dev/null +++ b/apps/meseeks/convex/reactor/settle.private.ts @@ -0,0 +1,189 @@ +import type { Doc, Id } from 'convex/_generated/dataModel'; +import { performResultSchema } from 'schemas/reactorSchema'; +import { defineMutation } from 'lib/convex'; +import { recordActionDetail } from '../action/details.private'; +import { enqueueAction, findAction } from '../actions.private'; +import { scheduleMutationTriggerReactions } from '../triggers.private'; +import { applyActionOutputFile, applyCompileMutation, applyFileMutation, applyTriggerMutation } from './apply.private'; +import { claimNextAction } from './claim.private'; + +export const settleAction = defineMutation({ + args: performResultSchema, + handler: async (ctx, args) => { + // + const action = await findAction(ctx, { action: args.action }); + if (action.status !== 'running') throw new Error('Action is not running.'); + + const revisions: Array> = []; + const revisionRows: Array> = []; + const triggers: Array> = []; + const paths: Array = []; + + for (const mutation of args.fileMutations ?? []) { + const applied = await applyFileMutation(ctx, { + owner: action.owner, + action: action._id, + mutation, + }); + const appliedRevisions = applied.revisions ?? (applied.revision ? [applied.revision] : []); + for (const revisionId of appliedRevisions) { + revisions.push(revisionId); + const revision = await ctx.db.get(revisionId); + if (revision) revisionRows.push(revision); + } + if (applied.path) paths.push(applied.path); + } + + for (const mutation of args.triggerMutations ?? []) { + const trigger = await applyTriggerMutation(ctx, { + owner: action.owner, + root: action.root, + action: action._id, + mutation, + }); + if (trigger) triggers.push(trigger); + } + + const compileDiagnostics: Array = []; + for (const mutation of args.compileMutations ?? []) { + compileDiagnostics.push( + ...(await applyCompileMutation(ctx, { + owner: action.owner, + root: action.root, + action: action._id, + mutation, + })), + ); + } + + const output = args.output + ? await applyActionOutputFile(ctx, { + owner: action.owner, + action: action._id, + root: action.root, + index: action.index, + body: args.output, + }) + : undefined; + + if (output) paths.push(output.path); + + const warnings = (args.warnings ?? []).concat(compileDiagnostics); + + if (revisions.length > 0 || paths.length > 0 || warnings.length > 0) { + await recordActionDetail(ctx, { + detail: { + owner: action.owner, + action: action._id, + createdAt: Date.now(), + kind: 'file', + file: output?.file._id, + revisions, + paths, + warnings, + }, + }); + } + + for (const trigger of triggers) { + await recordActionDetail(ctx, { + detail: { + owner: action.owner, + action: action._id, + createdAt: Date.now(), + kind: 'trigger', + trigger, + }, + }); + } + + if (args.providerReceipt) { + await recordActionDetail(ctx, { + detail: { + owner: action.owner, + action: action._id, + createdAt: Date.now(), + kind: 'provider', + ...args.providerReceipt, + }, + }); + } + + if (args.uploadTicket) { + await recordActionDetail(ctx, { + detail: { + owner: action.owner, + action: action._id, + createdAt: Date.now(), + kind: 'upload', + ...args.uploadTicket, + }, + }); + } + + if (args.error) { + await recordActionDetail(ctx, { + detail: { + owner: action.owner, + action: action._id, + createdAt: Date.now(), + kind: 'error', + message: args.error, + }, + }); + } + + await ctx.db.patch(action._id, { + status: args.status, + output: output?.file._id, + finishedAt: Date.now(), + warnings, + }); + + if (args.status === 'succeeded') { + const acceptedActions: Array> = []; + const spark = action.spark === 'self' ? action._id : action.spark; + for (const reaction of args.reactions ?? []) { + const acceptedAction = await enqueueAction(ctx, { + owner: action.owner, + root: action.root, + author: action._id, + spark, + skill: reaction.skill, + input: reaction.input, + }); + acceptedActions.push(acceptedAction); + } + if (acceptedActions.length > 0) { + await recordActionDetail(ctx, { + detail: { + owner: action.owner, + action: action._id, + createdAt: Date.now(), + kind: 'reaction', + proposals: args.reactions ?? [], + acceptedActions, + }, + }); + } + + const reactionRoots = await scheduleMutationTriggerReactions(ctx, { + action, + revisions: revisionRows, + }); + for (const root of reactionRoots) { + if (root === action.root) continue; + + await claimNextAction(ctx, { + owner: action.owner, + root, + }); + } + } + + await claimNextAction(ctx, { + owner: action.owner, + root: action.root, + }); + }, +}); diff --git a/apps/meseeks/convex/reactor/stage.private.ts b/apps/meseeks/convex/reactor/stage.private.ts new file mode 100644 index 00000000..3836fad4 --- /dev/null +++ b/apps/meseeks/convex/reactor/stage.private.ts @@ -0,0 +1,42 @@ +import type { Id } from 'convex/_generated/dataModel'; +import type { PerformResult, StagedText } from 'schemas/reactorSchema'; +import { deleteBody, storeText } from '../storage.private'; + +export async function stageText({ + owner, + content, + contentType, +}: { + owner: Id<'users'>; + content: string; + contentType: string; +}): Promise { + // + const stored = await storeText({ + owner, + content, + contentType, + }); + + return { + ...stored, + content, + contentType, + }; +} + +export async function cleanupStaged(result: PerformResult) { + // + const storageKeys: Array = []; + if (result.output) storageKeys.push(result.output.storageKey); + + for (const mutation of result.fileMutations ?? []) { + if (mutation.kind === 'createFile') storageKeys.push(mutation.body.storageKey); + if (mutation.kind === 'createText' || mutation.kind === 'writeText') storageKeys.push(mutation.body.storageKey); + if (mutation.kind === 'createTextAtPath') storageKeys.push(mutation.body.storageKey); + } + + for (const storageKey of storageKeys) { + await deleteBody({ storageKey }); + } +} diff --git a/apps/meseeks/convex/request.private.ts b/apps/meseeks/convex/request.private.ts new file mode 100644 index 00000000..96378172 --- /dev/null +++ b/apps/meseeks/convex/request.private.ts @@ -0,0 +1,95 @@ +import { z } from 'zod/v3'; +import type { Id } from 'convex/_generated/dataModel'; +import type { StagedText } from 'schemas/reactorSchema'; + +export const requestPreparationSchema = z.object({ + url: z.string().url(), + method: z + .enum([ + 'GET', // + 'POST', + 'PUT', + 'PATCH', + 'DELETE', + ]) + .default('GET'), + headers: z.record(z.string()).optional(), + body: z.unknown().optional(), + timeoutMs: z.number().int().positive().optional(), + warnings: z.array(z.string()).optional(), +}); + +export type RequestPreparation = z.infer; + +export function prepareRequest(args: z.input) { + // + return requestPreparationSchema.parse(args); +} + +export async function performRequest({ + action, + preparation, + stageText, + warnings, +}: { + action: { _id: Id<'actions'>; owner: Id<'users'> }; + preparation: RequestPreparation; + stageText(args: { owner: Id<'users'>; content: string; contentType: string }): Promise; + warnings: Array; +}) { + // + const request = requestPreparationSchema.parse(preparation); + const response = await fetch(request.url, { + method: request.method, + headers: request.headers, + body: formatBody(request.body), + }); + const text = await response.text(); + const headers: Record = {}; + + for (const [key, value] of response.headers) { + headers[key] = value; + } + + const content = [ + `# ${request.method} ${request.url}`, + '', + `Status: ${response.status}`, + '', + '```', + text, + '```', + ].join('\n'); + const output = await stageText({ + owner: action.owner, + content, + contentType: 'text/mdx; charset=utf-8', + }); + + return { + action: action._id, + status: response.ok ? ('succeeded' as const) : ('failed' as const), + output, + providerReceipt: { + provider: 'http', + request: { + url: request.url, + method: request.method, + headers: request.headers, + }, + response: { + status: response.status, + headers, + }, + }, + warnings, + }; +} + +function formatBody(body: unknown) { + // + if (body === undefined) return undefined; + if (typeof body === 'string') return body; + + return JSON.stringify(body); +} diff --git a/apps/meseeks/convex/schedule/lifecycle.private.ts b/apps/meseeks/convex/schedule/lifecycle.private.ts deleted file mode 100644 index 477c6f3a..00000000 --- a/apps/meseeks/convex/schedule/lifecycle.private.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { zid } from 'convex-helpers/server/zod3'; -import { z } from 'zod/v3'; -import { Id } from '../_generated/dataModel'; -import { MutationCtx } from '../_generated/server'; -import { addAction, skipPendingAuthorizationByTaskAuthor } from '../action.private'; -import { internal } from '../_generated/api'; -import { defineMutation } from 'lib/convex'; -import { computeNextRun } from 'lib/cron'; -import { authorSchema } from 'schemas/authorSchema'; -import { updateScheduleJobId, updateScheduleLastRun } from '../schedules.private'; - -export const executeOneTime = defineMutation({ - args: z.object({ - scheduleId: zid('schedules'), - taskId: zid('tasks'), - owner: zid('users'), - author: authorSchema, - skillKey: z.string(), - args: z.record(z.unknown()), - depth: z.number(), - }), - handler: async (ctx, { scheduleId, taskId, owner, author, skillKey, args, depth }) => { - // - await addAction(ctx, { - taskId, - owner, - author, - skillKey, - args, - depth, - }); - - await ctx.db.delete(scheduleId); - }, -}); - -export const executeRecurring = defineMutation({ - args: z.object({ - scheduleId: zid('schedules'), - taskId: zid('tasks'), - owner: zid('users'), - author: authorSchema, - skillKey: z.string(), - args: z.record(z.unknown()), - depth: z.number(), - cronExpression: z.string(), - timeZone: z.string(), - }), - handler: async (ctx, { scheduleId, taskId, owner, author, skillKey, args, depth, cronExpression, timeZone }) => { - // - await skipPendingAuthorizationByTaskAuthor(ctx, { - taskId, - author, - reasonText: 'superseded by a newer scheduled run', - }); - - await addAction(ctx, { - taskId, - owner, - author, - skillKey, - args, - depth, - }); - - const nextRunAt = computeNextRun(cronExpression, timeZone); - - const scheduledJobId = await scheduleExecution( - ctx, - { - taskId, - owner, - author, - skillKey, - args, - depth, - cronExpression, - timeZone, - scheduleType: 'recurring', - }, - nextRunAt, - scheduleId, - ); - - await updateScheduleJobId(ctx, { - scheduleId, - scheduledJobId, - }); - - await updateScheduleLastRun(ctx, { - scheduleId, - lastRunAt: Date.now(), - nextRunAt: nextRunAt.getTime(), - }); - }, -}); - -export async function scheduleExecution( - ctx: MutationCtx, - scheduleData: - | { - taskId: Id<'tasks'>; - owner: Id<'users'>; - author: z.infer; - skillKey: string; - args: Record; - depth: number; - scheduleType: 'one-time'; - timeZone: string; - delaySeconds?: number; - } - | { - taskId: Id<'tasks'>; - owner: Id<'users'>; - author: z.infer; - skillKey: string; - args: Record; - depth: number; - scheduleType: 'recurring'; - timeZone: string; - delaySeconds?: number; - cronExpression: string; - }, - scheduledDate: Date, - scheduleId: Id<'schedules'>, -): Promise { - // - if (scheduleData.scheduleType === 'one-time') { - return await ctx.scheduler.runAt(scheduledDate, internal.schedule.lifecycle._executeOneTime, { - scheduleId, - taskId: scheduleData.taskId, - owner: scheduleData.owner, - author: scheduleData.author, - skillKey: scheduleData.skillKey, - args: scheduleData.args, - depth: scheduleData.depth, - }); - } - - return await ctx.scheduler.runAt(scheduledDate, internal.schedule.lifecycle._executeRecurring, { - scheduleId, - taskId: scheduleData.taskId, - owner: scheduleData.owner, - author: scheduleData.author, - skillKey: scheduleData.skillKey, - args: scheduleData.args, - depth: scheduleData.depth, - cronExpression: scheduleData.cronExpression, - timeZone: scheduleData.timeZone, - }); -} diff --git a/apps/meseeks/convex/schedule/lifecycle.ts b/apps/meseeks/convex/schedule/lifecycle.ts deleted file mode 100644 index 8171f8db..00000000 --- a/apps/meseeks/convex/schedule/lifecycle.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { internalMutation } from 'lib/convex'; -import { executeOneTime, executeRecurring } from './lifecycle.private'; - -// scheduled by schedule/lifecycle.private.ts via ctx.scheduler.runAt for one-time schedules -export const _executeOneTime = internalMutation({ - args: executeOneTime.args.shape, - handler: executeOneTime, -}); - -// scheduled by schedule/lifecycle.private.ts via ctx.scheduler.runAt for recurring schedules -export const _executeRecurring = internalMutation({ - args: executeRecurring.args.shape, - handler: executeRecurring, -}); diff --git a/apps/meseeks/convex/schedules.private.ts b/apps/meseeks/convex/schedules.private.ts deleted file mode 100644 index 12f46904..00000000 --- a/apps/meseeks/convex/schedules.private.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { zid } from 'convex-helpers/server/zod3'; -import { z } from 'zod/v3'; -import { Id } from './_generated/dataModel'; -import { defineMutation, defineQuery } from 'lib/convex'; -import { NotFound } from 'lib/errors'; -import { computeNextRun, isExpressionValid } from 'lib/cron'; -import { authorSchema } from 'schemas/authorSchema'; -import { scheduleExecution } from './schedule/lifecycle.private'; - -const scheduledFunctionIdSchema = zid('_scheduled_functions'); - -export const createSchedule = defineMutation({ - args: z.object({ - taskId: zid('tasks'), - owner: zid('users'), - author: authorSchema, - skillKey: z.string(), - args: z.record(z.unknown()), - depth: z.number().min(0).max(1000), - scheduleType: z.enum(['one-time', 'recurring']), - timeZone: z.string(), - delaySeconds: z.number().optional(), - scheduledAt: z.string().optional(), // ISO string - cronExpression: z.string().optional(), - }), - handler: async (ctx, scheduleData) => { - // - validateScheduleData(scheduleData); - - const nextRunDate = calculateNextRun(scheduleData); - const scheduleId = await ctx.db.insert('schedules', buildRecord(scheduleData, nextRunDate)); - let scheduledJobId: string; - - if (scheduleData.scheduleType === 'one-time') { - scheduledJobId = await scheduleExecution( - ctx, - { - taskId: scheduleData.taskId, - owner: scheduleData.owner, - author: scheduleData.author, - skillKey: scheduleData.skillKey, - args: scheduleData.args, - depth: scheduleData.depth, - scheduleType: 'one-time', - timeZone: scheduleData.timeZone, - delaySeconds: scheduleData.delaySeconds, - }, - nextRunDate, - scheduleId, - ); - } else { - // - if (!scheduleData.cronExpression) throw new Error('Cron expression is required for recurring schedules'); - - scheduledJobId = await scheduleExecution( - ctx, - { - taskId: scheduleData.taskId, - owner: scheduleData.owner, - author: scheduleData.author, - skillKey: scheduleData.skillKey, - args: scheduleData.args, - depth: scheduleData.depth, - scheduleType: 'recurring', - timeZone: scheduleData.timeZone, - delaySeconds: scheduleData.delaySeconds, - cronExpression: scheduleData.cronExpression, - }, - nextRunDate, - scheduleId, - ); - } - - await updateScheduleJobId(ctx, { scheduleId, scheduledJobId }); - - return scheduleId; - }, -}); - -export const findTaskSchedules = defineQuery({ - args: z.object({ - taskId: zid('tasks'), - }), - handler: async (ctx, { taskId }) => { - // - return await ctx.db - .query('schedules') - .withIndex('by_task', (q) => q.eq('taskId', taskId)) - .collect(); - }, -}); - -export const updateScheduleLastRun = defineMutation({ - args: z.object({ - scheduleId: zid('schedules'), - lastRunAt: z.number(), - nextRunAt: z.number().optional(), - }), - handler: async (ctx, { scheduleId, lastRunAt, nextRunAt }) => { - // - const schedule = await ctx.db.get(scheduleId); - if (!schedule) throw NotFound(); - - await ctx.db.patch(scheduleId, { lastRunAt }); - - if (nextRunAt) { - await ctx.db.patch(scheduleId, { nextRunAt }); - } - }, -}); - -export const cancelSchedule = defineMutation({ - args: z.object({ - scheduleId: zid('schedules'), - }), - handler: async (ctx, { scheduleId }) => { - // - const schedule = await ctx.db.get(scheduleId); - if (!schedule) return; - - if (schedule.scheduledJobId) { - const scheduledJobId = scheduledFunctionIdSchema.parse(schedule.scheduledJobId); - await ctx.scheduler.cancel(scheduledJobId); - } - - await ctx.db.delete(scheduleId); - }, -}); - -export const updateScheduleJobId = defineMutation({ - args: z.object({ - scheduleId: zid('schedules'), - scheduledJobId: z.string(), - }), - handler: async (ctx, { scheduleId, scheduledJobId }) => { - // - await ctx.db.patch(scheduleId, { scheduledJobId }); - }, -}); - -export const cancelTaskSchedules = defineMutation({ - args: z.object({ - taskId: zid('tasks'), - }), - handler: async (ctx, { taskId }) => { - // - const schedules = await findTaskSchedules(ctx, { taskId }); - - await Promise.all(schedules.map((schedule) => cancelSchedule(ctx, { scheduleId: schedule._id }))); - - return schedules.length; - }, -}); - -function validateScheduleData(scheduleData: { - scheduleType: 'one-time' | 'recurring'; - delaySeconds?: number; - scheduledAt?: string; - cronExpression?: string; -}) { - // - if (scheduleData.scheduleType === 'one-time') { - // - if (!scheduleData.delaySeconds && !scheduleData.scheduledAt) { - throw new Error('One-time schedules require either delaySeconds or scheduledAt'); - } - if (scheduleData.delaySeconds && scheduleData.scheduledAt) { - throw new Error('Provide either delaySeconds OR scheduledAt, not both'); - } - if (scheduleData.cronExpression) { - throw new Error('One-time schedules cannot have cronExpression'); - } - - if (scheduleData.delaySeconds && scheduleData.delaySeconds < 0) { - throw new Error('delaySeconds must be positive or zero'); - } - - if (scheduleData.scheduledAt) { - // - const date = new Date(scheduleData.scheduledAt); - if (isNaN(date.getTime())) { - throw new Error(`Invalid ISO8601 datetime: "${scheduleData.scheduledAt}"`); - } - if (date.getTime() <= Date.now()) { - throw new Error('Scheduled time must be in the future'); - } - } - } else { - // - if (!scheduleData.cronExpression) { - throw new Error('Recurring schedules require cronExpression'); - } - - if (scheduleData.delaySeconds || scheduleData.scheduledAt) { - throw new Error('Recurring schedules cannot have delaySeconds or scheduledAt'); - } - - if (!isExpressionValid(scheduleData.cronExpression)) { - throw new Error('Invalid cron expression'); - } - } -} - -function calculateNextRun(scheduleData: { - scheduleType: 'one-time' | 'recurring'; - delaySeconds?: number; - scheduledAt?: string; - cronExpression?: string; - timeZone: string; -}) { - // - if (scheduleData.scheduleType === 'one-time') { - // - if (scheduleData.delaySeconds) { - const nextRunAt = Date.now() + scheduleData.delaySeconds * 1000; - return new Date(nextRunAt); - } - - return new Date(scheduleData.scheduledAt!); - } - - return computeNextRun(scheduleData.cronExpression!, scheduleData.timeZone); -} - -function buildRecord( - scheduleData: { - taskId: Id<'tasks'>; - owner: Id<'users'>; - author: z.infer; - skillKey: string; - args: Record; - depth: number; - scheduleType: 'one-time' | 'recurring'; - timeZone: string; - scheduledAt?: string; - cronExpression?: string; - }, - nextRunDate: Date, -) { - // - const baseRecord = { - taskId: scheduleData.taskId, - owner: scheduleData.owner, - author: scheduleData.author, - skillKey: scheduleData.skillKey, - args: scheduleData.args, - timeZone: scheduleData.timeZone, - nextRunAt: nextRunDate.getTime(), - }; - - if (scheduleData.scheduleType === 'one-time') { - // - return { - ...baseRecord, - scheduleType: 'one-time' as const, - scheduledAt: nextRunDate.getTime(), - }; - } - - return { - ...baseRecord, - scheduleType: 'recurring' as const, - cronExpression: scheduleData.cronExpression!, - }; -} diff --git a/apps/meseeks/convex/schedules.ts b/apps/meseeks/convex/schedules.ts deleted file mode 100644 index ddb6c74f..00000000 --- a/apps/meseeks/convex/schedules.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { zid } from 'convex-helpers/server/zod3'; -import { internalMutation, internalQuery, mutation, query } from 'lib/convex'; -import { NotFound } from 'lib/errors'; -import { cancelSchedule, createSchedule, findTaskSchedules } from './schedules.private'; -import { ensureTaskOwner } from './tasks.private'; -import { getCurrentUser } from './users.private'; - -const scheduledFunctionIdSchema = zid('_scheduled_functions'); - -// called by skills/builtIn/schedule.ts so the ai can create schedules -export const _create = internalMutation({ - args: createSchedule.args.shape, - handler: createSchedule, -}); - -// used by magicRock.private.ts to render {{taskSchedules}} from the same normalized listing logic -export const _findByTask = internalQuery({ - args: findTaskSchedules.args.shape, - handler: findTaskSchedules, -}); - -// called by skills/builtIn/cancelSchedule.ts so the ai can cancel schedules -export const _cancel = internalMutation({ - args: cancelSchedule.args.shape, - handler: cancelSchedule, -}); - -export const cancel = mutation({ - args: { - scheduleId: zid('schedules'), - }, - handler: async (ctx, { scheduleId }) => { - // - const schedule = await ctx.db.get(scheduleId); - if (!schedule) throw NotFound(); - - await ensureTaskOwner(ctx, { taskId: schedule.taskId }); - - if (schedule.scheduledJobId) { - // - const scheduledJobId = scheduledFunctionIdSchema.parse(schedule.scheduledJobId); - await ctx.scheduler.cancel(scheduledJobId); - } - - await ctx.db.delete(scheduleId); - }, -}); - -export const findByTask = query({ - args: { - taskId: zid('tasks'), - }, - handler: async (ctx, { taskId }) => { - // - await ensureTaskOwner(ctx, { taskId }); - - return await findTaskSchedules(ctx, { taskId }); - }, -}); - -export const findByOwner = query({ - args: {}, - handler: async (ctx) => { - // - const currentUser = await getCurrentUser(ctx, {}); - - const schedules = await ctx.db - .query('schedules') - .withIndex('by_owner', (q) => q.eq('owner', currentUser._id)) - .order('desc') - .collect(); - - // Fetch task titles in parallel - const schedulesWithTasks = await Promise.all( - schedules.map(async (schedule) => { - // - const task = await ctx.db.get(schedule.taskId); - return { - ...schedule, - taskTitle: task?.title || 'Untitled task', - }; - }), - ); - - return schedulesWithTasks; - }, -}); diff --git a/apps/meseeks/convex/schema.ts b/apps/meseeks/convex/schema.ts index bad218e6..eb6b6adb 100644 --- a/apps/meseeks/convex/schema.ts +++ b/apps/meseeks/convex/schema.ts @@ -2,16 +2,16 @@ import { zodToConvex } from 'convex-helpers/server/zod3'; import { defineSchema, defineTable } from 'convex/server'; import { actionDetailSchema } from 'schemas/actionDetailSchema'; import { actionSchema } from 'schemas/actionSchema'; -import { componentSchema } from 'schemas/componentSchema'; -import { draftSchema } from 'schemas/draftSchema'; -import { polarEventSchema } from 'schemas/polarEventSchema'; -import { scheduleSchema } from 'schemas/scheduleSchema'; +import { boxSchema } from 'schemas/boxSchema'; +import { fileRevisionSchema } from 'schemas/fileRevisionSchema'; +import { fileSchema, fileTagSchema } from 'schemas/fileSchema'; +import { pageSchema } from 'schemas/pageSchema'; +import { polarEventReceiptSchema } from 'schemas/polarEventSchema'; import { skillSchema } from 'schemas/skillSchema'; -import { subscriptionSchema } from 'schemas/subscriptionSchema'; -import { taskSchema } from 'schemas/taskSchema'; import { topUpSchema } from 'schemas/topUpSchema'; import { transactionSchema } from 'schemas/transactionSchema'; -import { userPreferencesSchema, userRequestSchema, userSchema } from 'schemas/userSchema'; +import { triggerSchema } from 'schemas/triggerSchema'; +import { userRequestSchema, userSchema } from 'schemas/userSchema'; // oxfmt-ignore export default defineSchema({ @@ -24,14 +24,6 @@ export default defineSchema({ 'email', ['email'], ).index( 'phone', ['phone'], - ).index( - 'walletAddress_chain', ['walletAddress', 'walletChain'], - ), - - user_preferences: defineTable( - zodToConvex(userPreferencesSchema), - ).index( - 'by_owner_key', ['owner', 'key'], ), user_requests: defineTable( @@ -40,45 +32,44 @@ export default defineSchema({ 'by_owner_key', ['owner', 'key'], ), - drafts: defineTable( - zodToConvex(draftSchema), + files: defineTable( + zodToConvex(fileSchema), ).index( - 'by_owner_taskId', ['owner', 'taskId'], + 'by_owner_parent_name', ['owner', 'parent', 'name'], ), - tasks: defineTable( - zodToConvex(taskSchema), + file_tags: defineTable( + zodToConvex(fileTagSchema), ).index( - 'by_owner_parentId_isActive', ['owner', 'parentId', 'isActive'], + 'by_file_key', ['file', 'key'], ).index( - 'by_parent_isActive', ['parentId', 'isActive'], + 'by_owner_key_value', ['owner', 'key', 'value'], + ), + + file_revisions: defineTable( + zodToConvex(fileRevisionSchema), + ).index( + 'by_file', ['file'], ).index( - 'by_owner_isActive', ['owner', 'isActive'], + 'by_file_previousRevision', ['file', 'previousRevision'], ).index( - 'by_owner_status', ['owner', 'status'], + 'by_action', ['action'], ).index( - 'by_owner_energyAvailable', ['owner', 'energyBudget.available'], + 'by_owner_file', ['owner', 'file'], ), - // .index( - // 'by_embeddingId', ['embeddingId'], - // ), - // taskEmbeddings: defineTable( - // zodToConvex(taskEmbeddingsSchema), - // ).vectorIndex("by_embedding", { - // dimensions: 3072, - // vectorField: 'embedding', - // filterFields: ['isDone'], - // }), - actions: defineTable( zodToConvex(actionSchema), ).index( - 'by_task', ['taskId'], + 'by_root_index', ['root', 'index'], + ).index( + 'by_root_status', ['root', 'status'], + ).index( + 'by_owner_root', ['owner', 'root'], ).index( - 'by_task_status', ['taskId', 'status'], + 'by_author', ['author'], ).index( - 'by_task_author_status', ['taskId', 'author', 'status'], + 'by_spark', ['spark'], ).index( 'by_status', ['status'], ), @@ -86,53 +77,66 @@ export default defineSchema({ action_details: defineTable( zodToConvex(actionDetailSchema), ).index( - 'by_action', ['actionId'], - ), - - schedules: defineTable( - zodToConvex(scheduleSchema), + 'by_action', ['action'], ).index( - 'by_task', ['taskId'], + 'by_owner_action', ['owner', 'action'], ).index( - 'by_owner', ['owner'], + 'by_kind', ['kind'], ), - skills: defineTable( - zodToConvex(skillSchema), + triggers: defineTable( + zodToConvex(triggerSchema), ).index( - 'by_owner_kind', ['owner', 'kind'], + 'by_root_kind_status', ['root', 'kind', 'status'], ).index( - 'by_owner_key', ['owner', 'key'], + 'by_owner_root', ['owner', 'root'], + ).index( + 'by_nextRunAt', ['nextRunAt'], + ).index( + 'by_author', ['author'], ), - components: defineTable( - zodToConvex(componentSchema), + boxes: defineTable( + zodToConvex(boxSchema), ).index( - 'by_owner_slug', ['owner', 'slug'], + 'by_root_status', ['root', 'status'], + ).index( + 'by_owner_root', ['owner', 'root'], + ).index( + 'by_providerBoxId', ['providerBoxId'], ), transactions: defineTable( zodToConvex(transactionSchema), ).index( 'by_owner', ['owner'], - ).searchIndex( - 'search_transactions', { - searchField: 'description', - filterFields: ['owner', 'kind'], - } + ).index( + 'by_action', ['action'], + ).index( + 'by_file', ['file'], + ).index( + 'by_topUp', ['topUp'], ), - subscriptions: defineTable( - zodToConvex(subscriptionSchema), + skills: defineTable( + zodToConvex(skillSchema), ).index( - 'by_owner_status', ['owner', 'status'], + 'by_owner_kind', ['owner', 'kind'], ).index( - 'by_paymentId', ['paymentId'], + 'by_owner_key', ['owner', 'key'], + ).index( + 'by_owner_root_key', ['owner', 'root', 'key'], + ), + + pages: defineTable( + zodToConvex(pageSchema), + ).index( + 'by_owner_root_route', ['owner', 'root', 'route'], ).index( - 'by_polarSubscriptionId', ['polarSubscriptionId'], + 'by_file', ['file'], ), - topUps: defineTable( + top_ups: defineTable( zodToConvex(topUpSchema), ).index( 'by_status_owner', ['status', 'owner'], @@ -140,7 +144,13 @@ export default defineSchema({ 'by_paymentId', ['paymentId'], ), - polarEvents: defineTable( - zodToConvex(polarEventSchema), + polar_events: defineTable( + zodToConvex(polarEventReceiptSchema), + ).index( + 'by_eventId', ['eventId'], + ).index( + 'by_owner', ['owner'], + ).index( + 'by_action', ['action'], ), }); diff --git a/apps/meseeks/convex/seed.ts b/apps/meseeks/convex/seed.ts deleted file mode 100644 index 949ed07b..00000000 --- a/apps/meseeks/convex/seed.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { z } from 'zod/v3'; -import { internalMutation } from 'lib/convex'; -import { asBigInt } from 'lib/money'; -import { zodToString } from 'lib/zodToString'; -import { skillSchema } from 'schemas/skillSchema'; -import type { MutationCtx } from './_generated/server'; - -const isPro = 'isPro'; - -const defaultComponents = [ - { - slug: 'list', - body: '', - }, - { - slug: 'task', - body: '', - }, - { - slug: 'new', - body: '', - }, -]; - -const defaultSkills = [ - skillSchema.parse({ - kind: 'soft', - key: 'instruct', - cost: 'dynamic', - priority: 900, - preApprovedCost: asBigInt({ dollars: 0.5 }), - owner: isPro, - author: 'built-in', - description: 'Infer the user intent and decide the next task action.', - inputSchema: zodToString(z.object({})), - config: { - model: 'auto', - historyMode: 'since last instructed', - availableSkills: [ - 'askForClarification', // - 'discard', - 'updateInstructions', - 'iterate', - ], - temperature: 0.5, - instructions: [ - `You are the task-intent router. Infer what the user's latest action means for the current task, then call exactly one tool.`, - `Do not answer the user directly. Do not output free text.`, - `Call updateInstructions() when the latest user action changes, corrects, narrows, expands, or adds context to the task.`, - `Call iterate() when the current title/instructions already represent the user's intent and the task should continue.`, - `Call askForClarification() only when a missing critical detail would force guessing the task branch.`, - `Call discard() only when the user clearly wants to cancel, abandon, archive, delete, stop, or mark the task irrelevant.`, - `When updating instructions, keep title under 60 characters, put future work in instructions, and put past corrections or wrong assumptions in summary.`, - `Available skills selected for the task must come from the active skills list when possible.`, - ``, - ` {{userInfo}}`, - ` {{activeSkills}}`, - ` {{task}}`, - ` {{taskSchedules}}`, - ` {{currentDate}}`, - ``, - ].join('\n'), - }, - }), - skillSchema.parse({ - kind: 'soft', - key: 'iterate', - cost: 'dynamic', - priority: 1000, - preApprovedCost: asBigInt({ dollars: 0.5 }), - owner: isPro, - author: 'built-in', - description: 'Progress the task until it is resolved or blocked.', - inputSchema: zodToString(z.object({})), - config: { - model: 'auto', - historyMode: 'since last instructed', - availableSkills: ['{{taskSkills}}', 'done', 'say', 'reason', 'setUserInfo', 'schedule', 'cancelSchedule'], - temperature: 0.7, - instructions: [ - `Progress the task by calling exactly one tool.`, - `Use the current task title, instructions, summary, and recent history as the source of truth.`, - `For direct questions, translations, simple explanations, and small decisions, call say() with a compact answer, then call done() on the next iteration if the answer is sufficient.`, - `Use task-selected skills when they are clearly useful. Skills with empty input schemas are valid; call them with {}.`, - `Use reason() when a short reasoning step would help decide the next action.`, - `Use schedule() for reminders, delayed work, recurring checks, and monitoring. After scheduling, stop the loop with done() unless more immediate work is required.`, - `Call done({ reason: "resolved" }) when the task is complete.`, - `Call done({ reason: "blocked", message }) when progress requires user input or external access you do not have.`, - `Do not repeat a previous say() unless there is new information.`, - ``, - ` {{userInfo}}`, - ` {{activeSkills}}`, - ` {{task}}`, - ` {{taskSchedules}}`, - ` {{currentDate}}`, - ``, - ].join('\n'), - }, - }), -]; - -export const _all = internalMutation({ - args: z.object({}), - handler: async (ctx) => { - // - const components = await seedComponents(ctx); - const skills = await seedSkills(ctx); - - return { components, skills }; - }, -}); - -async function seedComponents(ctx: MutationCtx) { - // - let inserted = 0; - let updated = 0; - - for (const component of defaultComponents) { - // - const existing = await ctx.db - .query('components') - .withIndex('by_owner_slug', (q) => q.eq('owner', isPro).eq('slug', component.slug)) - .unique(); - - if (existing) { - await ctx.db.patch(existing._id, { body: component.body, isPublic: false }); - updated += 1; - continue; - } - - await ctx.db.insert('components', { - owner: isPro, - slug: component.slug, - body: component.body, - isPublic: false, - }); - - inserted += 1; - } - - return { inserted, updated }; -} - -async function seedSkills(ctx: MutationCtx) { - // - let inserted = 0; - let updated = 0; - - for (const skill of defaultSkills) { - // - const existing = await ctx.db - .query('skills') - .withIndex('by_owner_key', (q) => q.eq('owner', isPro).eq('key', skill.key)) - .unique(); - - if (existing) { - await ctx.db.patch(existing._id, skill); - updated += 1; - continue; - } - - await ctx.db.insert('skills', skill); - inserted += 1; - } - - return { inserted, updated }; -} diff --git a/apps/meseeks/convex/skills.private.ts b/apps/meseeks/convex/skills.private.ts index 691c0b64..0647b532 100644 --- a/apps/meseeks/convex/skills.private.ts +++ b/apps/meseeks/convex/skills.private.ts @@ -1,107 +1,20 @@ import { zid } from 'convex-helpers/server/zod3'; import { z } from 'zod/v3'; +import type { Doc, Id } from './_generated/dataModel'; +import type { MutationCtx } from './_generated/server'; import { defineMutation, defineQuery } from 'lib/convex'; import { bigIntFromJSON } from 'lib/bigintJson'; import { NotFound } from 'lib/errors'; -import { isString } from 'lib/guards'; -import { zodToString } from 'lib/zodToString'; import { - builtInSkillSchema, newSkillSchema, - simplifiedSkillKindSchema, + preApprovedCostSchema, + skillCostSchema, + skillKeySchema, + skillKindSchema, skillOwnerSchema, skillSchema, } from 'schemas/skillSchema'; -import { ensureInputSchemaIsValid } from 'skills/builtIn/createSkill'; -import { _builtInSkills } from 'skills/builtIn/index'; -import { getCurrentUser } from './users.private'; - -export function buildInSkillToDoc( - key: string, // - skill: (typeof _builtInSkills)[keyof typeof _builtInSkills], -) { - return builtInSkillSchema.parse({ - key, - description: skill.description, - inputSchema: zodToString(skill.parameters), - preApprovedCost: skill.preApprovedCost, - knownReactions: skill.knownReactions, - kind: 'built-in', - owner: 'built-in', - author: 'built-in', - cost: 0n, - }); -} - -export function isBuiltInSkillKey( - skillKey: string, // -): skillKey is keyof typeof _builtInSkills { - // - return skillKey in _builtInSkills; -} - -export const ensureSkillOwner = defineQuery({ - args: z.object({ - skillId: zid('skills'), - }), - handler: async (ctx, { skillId }) => { - // - const currentUser = await getCurrentUser(ctx, {}); - const skill = await ctx.db.get(skillId); - - if (!skill) throw NotFound(); - if (skill.owner !== currentUser._id) throw NotFound(); // purposefully do not mention authorization - - return { currentUser, skill }; - }, -}); - -const getUserPreference = defineQuery({ - args: z.object({ - userId: zid('users'), - key: z.string(), - }), - handler: async (ctx, { userId, key }) => { - // - return await ctx.db - .query('user_preferences') - .withIndex('by_owner_key', (q) => q.eq('owner', userId).eq('key', key)) - .unique(); - }, -}); - -const setUserPreference = defineMutation({ - args: z.object({ - userId: zid('users'), - key: z.string(), - value: z.unknown(), - }), - handler: async (ctx, { userId, key, value }) => { - // - const preference = await getUserPreference(ctx, { userId, key }); - - if (!preference) { - await ctx.db.insert('user_preferences', { owner: userId, key, value }); - } else { - await ctx.db.patch(preference._id, { value }); - } - }, -}); - -export const findEnabledSkillKeys = defineQuery({ - args: z.object({ - userId: zid('users'), - }), - handler: async (ctx, { userId }) => { - // - const enabledSkills = await getUserPreference(ctx, { - key: 'enabledSkills', - userId, - }); - - return Array.isArray(enabledSkills?.value) ? enabledSkills.value.filter(isString) : []; - }, -}); +import { findInstinct, listInstincts } from './instincts/index.private'; export const findAllSkillKeys = defineQuery({ args: z.object({ @@ -109,65 +22,28 @@ export const findAllSkillKeys = defineQuery({ }), handler: async (ctx, { userId }) => { // - const dbList = await findAllSkills(ctx, { owner: userId }).then((list) => - list.map((skill) => ({ - key: skill.key, - description: skill.description, - })), - ); - - const builtInList = Object.entries(_builtInSkills).map(([key, builtInTool]) => ({ - key, - description: builtInTool.description, - })); + const skills = await findAllSkills(ctx, { owner: userId }); - return dbList.concat(builtInList); + return skills.map((skill) => ({ + key: skill.key, + description: skill.description, + })); }, }); -export const findEnabledSkillsWithDetails = defineQuery({ +export const findAllSkillsWithDetails = defineQuery({ args: z.object({ userId: zid('users'), }), handler: async (ctx, { userId }) => { // - const [enabledSkillKeys, allSkills] = await Promise.all([ - findEnabledSkillKeys(ctx, { userId }), - findAllSkills(ctx, { owner: userId }), - ]); - - // Create a map of all available skills for fast lookup - const allSkillsMap = new Map(); - - // Add database skills (user + global) - for (const skill of allSkills) { - allSkillsMap.set(skill.key, { - key: skill.key, - description: skill.description, - inputSchema: skill.inputSchema, - }); - } + const allSkills = await findAllSkills(ctx, { owner: userId }); - // Add built-in skills - for (const [key, builtInTool] of Object.entries(_builtInSkills)) { - allSkillsMap.set(key, { - key, - description: builtInTool.description, - inputSchema: zodToString(builtInTool.parameters), - }); - } - - // Filter to only enabled skills - const enabledSkillsSet = new Set(enabledSkillKeys); - const skillDetails = []; - - for (const [key, skill] of allSkillsMap) { - if (enabledSkillsSet.has(key)) { - skillDetails.push(skill); - } - } - - return skillDetails; + return allSkills.map((skill) => ({ + key: skill.key, + description: skill.description, + inputSchema: skill.inputSchema, + })); }, }); @@ -179,15 +55,16 @@ export const createSkill = defineMutation({ handler: async (ctx, { skill, userId }) => { // // TODO: bad logic, improve this + if (findInstinct(skill.key)) throw new Error(`Skill key '${skill.key}' is reserved.`); + const existing = await findSkillSafe(ctx, { key: skill.key, owner: userId }); if (existing) throw new Error(`Skill key '${skill.key}' in use.`); - ensureInputSchemaIsValid(skill.inputSchema); - return await ctx.db.insert('skills', { ...skill, owner: userId, author: userId, + source: 'manual', }); }, }); @@ -199,43 +76,137 @@ export const updateSkill = defineMutation({ }), handler: async (ctx, { skill, userId }) => { // + if (findInstinct(skill.key)) throw new Error(`Skill key '${skill.key}' is reserved.`); + const existing = await findSkill(ctx, { key: skill.key, owner: userId }); if (!existing) throw NotFound(); if (existing.owner !== userId) throw NotFound(); - if (!('_id' in existing)) throw NotFound(); // built-in skills do not have an _id - - ensureInputSchemaIsValid(skill.inputSchema); return await ctx.db.patch(existing._id, { ...skill, + source: 'manual', }); }, }); -export const enableSkill = defineMutation({ - args: z.object({ - userId: zid('users'), - skillKey: z.string(), - }), - handler: async (ctx, { userId, skillKey }) => { +export const ensureInstinctSkillRows = defineMutation({ + args: z.object({}), + handler: async (ctx) => { // - const enabledSkills = await getUserPreference(ctx, { - userId, - key: 'enabledSkills', - }); + const existingSkills = await findAllSkillsByOwner(ctx, { owner: 'isPro' }); + const existingByKey = new Map(existingSkills.map((skill) => [skill.key, skill])); + const seen = new Set(); + let inserted = 0; + let updated = 0; + + for (const instinct of listInstincts()) { + const row = skillSchema.parse({ + key: instinct.key, + description: instinct.description, + inputSchema: instinct.inputSchema, + outputSchema: instinct.outputSchema, + preApprovedCost: preApprovedCostSchema.parse('none'), + owner: 'isPro', + author: 'isPro', + source: 'instinct', + kind: instinct.kind, + cost: skillCostSchema.parse('dynamic'), + config: {}, + isHidden: false, + priority: 0, + }); + seen.add(row.key); - const currentSkills = Array.isArray(enabledSkills?.value) ? enabledSkills.value.filter(isString) : []; - if (currentSkills.includes(skillKey)) return; + const existing = existingByKey.get(row.key); + if (existing) { + await ctx.db.patch(existing._id, row); + updated += 1; + continue; + } - await setUserPreference(ctx, { - userId, - key: 'enabledSkills', - value: currentSkills.concat(skillKey), - }); + await ctx.db.insert('skills', row); + inserted += 1; + } + + for (const skill of existingSkills) { + if (skill.source !== 'instinct') continue; + if (seen.has(skill.key)) continue; + + await ctx.db.delete(skill._id); + } + + return { inserted, updated }; }, }); +export async function replaceCompiledSkillsForRoot( + ctx: MutationCtx, + args: { + owner: Id<'users'>; + root: Id<'files'>; + action: Id<'actions'>; + skills: Array<{ + key: string; + description: string; + kind: 'think' | 'request' | 'execute'; + inputSchema: string; + outputSchema: string; + config: Record; + sourceFile: Id<'files'>; + sourcePath: string; + sourceHash?: string; + }>; + }, +) { + // + const existing = await ctx.db + .query('skills') + .withIndex('by_owner_kind', (q) => q.eq('owner', args.owner)) + .collect(); + const existingCompiled = existing.filter((skill) => skill.source === 'file' && skill.root === args.root); + const existingByKey = new Map(existingCompiled.map((skill) => [skill.key, skill])); + const nextKeys = new Set(); + const compiledAt = Date.now(); + + for (const skill of args.skills) { + nextKeys.add(skill.key); + const row = skillSchema.parse({ + key: skill.key, + description: skill.description, + inputSchema: skill.inputSchema, + outputSchema: skill.outputSchema, + preApprovedCost: preApprovedCostSchema.parse('none'), + owner: args.owner, + author: args.action, + source: 'file', + root: args.root, + sourceFile: skill.sourceFile, + sourcePath: skill.sourcePath, + sourceHash: skill.sourceHash, + compiledBy: args.action, + compiledAt, + kind: skill.kind, + cost: skill.kind === 'request' ? 0n : 'dynamic', + config: skill.config, + }); + + const existingSkill = existingByKey.get(row.key); + if (existingSkill) { + await ctx.db.patch(existingSkill._id, row); + continue; + } + + await ctx.db.insert('skills', row); + } + + for (const skill of existingCompiled) { + if (nextKeys.has(skill.key)) continue; + + await ctx.db.delete(skill._id); + } +} + export const replaceProSkills = defineMutation({ args: z.object({ skills: z.array(z.unknown()), @@ -243,84 +214,52 @@ export const replaceProSkills = defineMutation({ }), handler: async (ctx, { skills: rawSkills, deleteUnspecified }) => { // - // convert __bigint__ markers back to BigInt - const skills = rawSkills.map((skill: unknown) => { - // - const converted = bigIntFromJSON(skill); - return skillSchema.parse(converted); - }); + const skills = rawSkills.map((skill) => skillSchema.parse(normalizeLegacySkill(bigIntFromJSON(skill)))); - console.info(`Replacing Pro-managed skills (deleteUnspecified: ${deleteUnspecified})`); - - // Validate all skills have the correct owner for (const skill of skills) { + if (findInstinct(skill.key)) throw new Error(`Skill key '${skill.key}' is reserved.`); if (skill.owner !== 'isPro') { throw new Error(`All skills must have owner "isPro", found: ${skill.owner}`); } } - // Validate no duplicate keys in new skills const newSkillKeys = new Set(); for (const skill of skills) { - if (newSkillKeys.has(skill.key)) { - throw new Error(`Duplicate skill key found: ${skill.key}`); - } + if (newSkillKeys.has(skill.key)) throw new Error(`Duplicate skill key found: ${skill.key}`); newSkillKeys.add(skill.key); } - console.info(`Validated ${skills.length} new skills with unique keys`); - // Get all existing "isPro" skills const existingSkills = await findAllSkillsByOwner(ctx, { owner: 'isPro' }); - console.info(`Found ${existingSkills.length} existing "isPro" skills`); - - // Create maps for easier lookup const existingSkillsByKey = new Map(existingSkills.map((skill) => [skill.key, skill])); + let inserted = 0; + let updated = 0; + let deleted = 0; - let insertedCount = 0; - let updatedCount = 0; - let deletedCount = 0; - const insertedSkillIds: string[] = []; - - // Process each new skill: insert if new, update if exists for (const skill of skills) { - // const existingSkill = existingSkillsByKey.get(skill.key); if (existingSkill) { await ctx.db.patch(existingSkill._id, skill); - updatedCount++; - } else { - const skillId = await ctx.db.insert('skills', skill); - insertedSkillIds.push(skillId); - insertedCount++; - console.debug(`Inserted skill: ${skill.key}`); + updated += 1; + continue; } + + await ctx.db.insert('skills', skill); + inserted += 1; } - // Delete skills that exist but are not in the new list (only if explicitly requested) for (const existingSkill of existingSkills) { - if (!newSkillKeys.has(existingSkill.key)) { - if (deleteUnspecified) { - await ctx.db.delete(existingSkill._id); - deletedCount++; - console.warn(`Deleted skill: ${existingSkill.key}`); - } else { - throw new Error( - `Skill ${existingSkill.key} is not in the new list and deleteUnspecified is false. DID NOTHING.`, - ); - } - } - } + if (newSkillKeys.has(existingSkill.key)) continue; + if (!deleteUnspecified) throw new Error(`Skill ${existingSkill.key} is not in the new list.`); - console.info( - `Operation completed: ${insertedCount} inserted, ${updatedCount} updated, ${deletedCount} deleted`, - ); + await ctx.db.delete(existingSkill._id); + deleted += 1; + } return { - success: true, - inserted: insertedCount, - updated: updatedCount, - deleted: deletedCount, + inserted, + updated, + deleted, skillKeys: skills.map((skill) => skill.key), deletedUnspecified: deleteUnspecified, }; @@ -330,7 +269,7 @@ export const replaceProSkills = defineMutation({ export const findAllSkillsByOwner = defineQuery({ args: z.object({ owner: skillOwnerSchema, - kind: simplifiedSkillKindSchema.optional(), + kind: skillKindSchema.optional(), }), handler: async (ctx, { owner, kind }) => { // @@ -347,22 +286,38 @@ export const findAllSkillsByOwner = defineQuery({ export const findSkillByOwner = defineQuery({ args: z.object({ - key: z.string(), + key: skillKeySchema, owner: skillOwnerSchema, + root: zid('files').optional(), }), - handler: async (ctx, { key, owner }) => { + handler: async (ctx, { key, owner, root }) => { // - return await ctx.db + if (root) { + const scoped = await ctx.db + .query('skills') + .withIndex('by_owner_root_key', (q) => + q + .eq('owner', owner) // + .eq('root', root) + .eq('key', key), + ) + .unique(); + if (scoped) return scoped; + } + + const unscoped = await ctx.db .query('skills') .withIndex('by_owner_key', (q) => q.eq('owner', owner).eq('key', key)) - .unique(); + .collect(); + + return unscoped.find((skill) => !skill.root); }, }); export const findAllSkills = defineQuery({ args: z.object({ owner: zid('users'), - kind: simplifiedSkillKindSchema.optional(), + kind: skillKindSchema.optional(), }), handler: async (ctx, { owner, kind }) => { // @@ -375,50 +330,69 @@ export const findAllSkills = defineQuery({ }, }); -export const findSkill = defineQuery({ +export const findSkillsForRoot = defineQuery({ args: z.object({ - key: z.string(), owner: zid('users'), + root: zid('files'), }), - handler: async (ctx, { key, owner }) => { + handler: async (ctx, { owner, root }) => { // - const globalSkill = await findSkillByOwner(ctx, { key, owner: 'isPro' }); - if (globalSkill) return globalSkill; + const [globals, personal] = await Promise.all([ + findAllSkillsByOwner(ctx, { owner: 'isPro' }), + findAllSkillsByOwner(ctx, { owner }), + ]); + const rows = new Map>(); - const userSkill = await findSkillByOwner(ctx, { key, owner }); - if (userSkill) return userSkill; + for (const skill of globals) { + if (skill.root) continue; - const builtInSkillEntry = Object.entries(_builtInSkills).find(([skillKey]) => skillKey === key); - - if (builtInSkillEntry) { - // - const [, builtInTool] = builtInSkillEntry; - - return builtInSkillSchema.parse({ - key, - description: builtInTool.description, - inputSchema: zodToString(builtInTool.parameters), - preApprovedCost: builtInTool.preApprovedCost, - kind: 'built-in', - owner: 'built-in', - author: 'built-in', - cost: 0n, - }); + rows.set(skill.key, skill); + } + + for (const skill of personal) { + if (skill.root) continue; + + rows.set(skill.key, skill); } + for (const skill of personal) { + if (skill.root !== root) continue; + + rows.set(skill.key, skill); + } + + return Array.from(rows.values()).sort(compareSkills); + }, +}); + +export const findSkill = defineQuery({ + args: z.object({ + key: skillKeySchema, + owner: zid('users'), + root: zid('files').optional(), + }), + handler: async (ctx, { key, owner, root }) => { + // + const userSkill = await findSkillByOwner(ctx, { key, owner, root }); + if (userSkill) return userSkill; + + const globalSkill = await findSkillByOwner(ctx, { key, owner: 'isPro' }); + if (globalSkill) return globalSkill; + throw new Error(`Unknown skill: ${key}`); }, }); export const findSkillSafe = defineQuery({ args: z.object({ - key: z.string(), + key: skillKeySchema, owner: zid('users'), + root: zid('files').optional(), }), - handler: async (ctx, { key, owner }) => { + handler: async (ctx, { key, owner, root }) => { // try { - return await findSkill(ctx, { key, owner }); + return await findSkill(ctx, { key, owner, root }); } catch (error) { if (error instanceof Error && error.message === `Unknown skill: ${key}`) { return undefined; @@ -427,3 +401,22 @@ export const findSkillSafe = defineQuery({ } }, }); + +function normalizeLegacySkill(skill: unknown) { + // + if (!skill || typeof skill !== 'object' || Array.isArray(skill)) return skill; + if ('source' in skill) return skill; + + return { + ...skill, + source: 'manual', + }; +} + +function compareSkills(left: { priority?: number; key: string }, right: { priority?: number; key: string }) { + // + const priority = (left.priority ?? 999999999) - (right.priority ?? 999999999); + if (priority !== 0) return priority; + + return left.key.localeCompare(right.key); +} diff --git a/apps/meseeks/convex/skills.ts b/apps/meseeks/convex/skills.ts index 5c05f284..ef69408d 100644 --- a/apps/meseeks/convex/skills.ts +++ b/apps/meseeks/convex/skills.ts @@ -1,43 +1,40 @@ import { zid } from 'convex-helpers/server/zod3'; -import { z } from 'zod/v3'; +import type { Doc } from './_generated/dataModel'; import { internalMutation, internalQuery, mutation, query } from 'lib/convex'; import { NotFound } from 'lib/errors'; -import { newSkillSchema, simplifiedSkillKindSchema } from 'schemas/skillSchema'; +import { newSkillSchema, skillKeySchema, skillKindSchema } from 'schemas/skillSchema'; import { - buildInSkillToDoc, createSkill, - enableSkill, findAllSkills, findAllSkillsByOwner, findAllSkillKeys, - findEnabledSkillsWithDetails, + findAllSkillsWithDetails, findSkill, - isBuiltInSkillKey, + findSkillsForRoot, replaceProSkills, updateSkill, } from './skills.private'; -import { _builtInSkills } from 'skills/builtIn/index'; +import { ensureScopeOwner } from './files.private'; import { getCurrentUser } from './users.private'; +import { findInstinct, listInstincts } from './instincts/index.private'; -// used by skills/tools.ts to load hard/soft skill docs before building ai tool definitions export const _findAll = internalQuery({ args: { owner: zid('users'), - kind: simplifiedSkillKindSchema.optional().describe('Filter by skill kind. Grab all if unspecified.'), + kind: skillKindSchema.optional(), }, handler: findAllSkills, }); -// used by builtIn/getSkillDetails.ts so the ai can inspect one skill by key/owner export const _findOne = internalQuery({ args: { - key: z.string(), + key: skillKeySchema, owner: zid('users'), + root: zid('files').optional(), }, handler: findSkill, }); -// used by magicRock.private.ts to expand {{allSkills}} in system instructions export const _findAllKeys = internalQuery({ args: { userId: zid('users'), @@ -45,42 +42,13 @@ export const _findAllKeys = internalQuery({ handler: findAllSkillKeys, }); -// used by magicRock.private.ts to expand {{activeSkills}} with enabled skill schemas -export const _findEnabledSkillsWithDetails = internalQuery({ +export const _findAllSkillsWithDetails = internalQuery({ args: { userId: zid('users'), }, - handler: findEnabledSkillsWithDetails, + handler: findAllSkillsWithDetails, }); -// called by builtIn/createSkill.ts to persist a generated skill from tool execution -export const _create = internalMutation({ - args: { - skill: newSkillSchema, - userId: zid('users'), - }, - handler: createSkill, -}); - -// called by builtIn/updateSkill.ts to patch a skill from tool execution -export const _update = internalMutation({ - args: { - skill: newSkillSchema, - userId: zid('users'), - }, - handler: updateSkill, -}); - -// called by builtIn/createSkill.ts and builtIn/updateSkill.ts to enable the created/updated skill -export const _enableSkill = internalMutation({ - args: { - userId: zid('users'), - skillKey: z.string(), - }, - handler: enableSkill, -}); - -// called by private/skills/deploy.ts to sync the db-backed isPro skill catalog export const _replaceProSkills = internalMutation({ args: replaceProSkills.args, handler: replaceProSkills, @@ -92,14 +60,7 @@ export const findAllPublic = query({ // const skills = await findAllSkillsByOwner(ctx, { owner: 'isPro' }); - // remove headers from isPro hard skills, as they may contain passwords - for (const skill of skills) { - if (skill.owner === 'isPro' && skill.kind === 'hard') { - skill.config.headers = {}; - } - } - - return skills; + return skills.map((skill) => redactSkill(skill)); }, }); @@ -112,29 +73,58 @@ export const findAllPersonal = query({ }, }); -export const findAllInnate = query({ +export const findByRoot = query({ + args: { + root: zid('files'), + }, + handler: async (ctx, { root }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + const actionRoot = await ensureScopeOwner(ctx, { + owner: currentUser._id, + directory: root, + }); + + const skills = await findSkillsForRoot(ctx, { + owner: currentUser._id, + root: actionRoot._id, + }); + + return skills.map((skill) => redactSkill(skill)); + }, +}); + +export const findAllInstincts = query({ args: {}, handler: async () => { // - const builtInTools = Object.entries(_builtInSkills) - .filter(([_, tool]) => !tool.hidden) - .sort(([_, a], [__, b]) => a.priority - b.priority); - - return builtInTools.map(([key, tool]) => buildInSkillToDoc(key, tool)); + return listInstincts().map((skill) => ({ + key: skill.key, + kind: skill.kind, + description: skill.description, + inputSchema: skill.inputSchema, + outputSchema: skill.outputSchema, + })); }, }); -export const findOneInnate = query({ +export const findOneInstinct = query({ args: { - skillKey: z.string(), + skillKey: skillKeySchema, }, - handler: async (ctx, { skillKey }) => { + handler: async (_ctx, { skillKey }) => { // - if (!isBuiltInSkillKey(skillKey)) throw NotFound(); - - const skill = _builtInSkills[skillKey]; + const skill = findInstinct(skillKey); + if (!skill) throw NotFound(); - return buildInSkillToDoc(skillKey, skill); + return { + key: skill.key, + kind: skill.key, + description: skill.description, + inputSchema: skill.inputSchema, + outputSchema: skill.outputSchema, + isEditable: false, + }; }, }); @@ -148,19 +138,10 @@ export const findOne = query({ const skill = await ctx.db.get(skillId); if (!skill) throw NotFound(); - - if (skill.owner !== currentUser._id && skill.owner !== 'isPro') { - // purposefully do not mention authorization - throw NotFound(); - } - - // remove headers from isPro hard skills, as they may contain passwords - if (skill.owner === 'isPro' && skill.kind === 'hard') { - skill.config.headers = {}; - } + if (skill.owner !== currentUser._id && skill.owner !== 'isPro') throw NotFound(); return { - ...skill, + ...redactSkill(skill), isEditable: skill.owner === currentUser._id, }; }, @@ -187,3 +168,19 @@ export const update = mutation({ return await updateSkill(ctx, { skill, userId: currentUser._id }); }, }); + +function redactSkill(skill: Doc<'skills'>) { + // + if (skill.owner !== 'isPro') return skill; + if (skill.source === 'instinct') return skill; + if (skill.kind !== 'request') return skill; + if (!skill.config || typeof skill.config !== 'object' || !('headers' in skill.config)) return skill; + + return { + ...skill, + config: { + ...skill.config, + headers: undefined, + }, + }; +} diff --git a/apps/meseeks/convex/storage.private.ts b/apps/meseeks/convex/storage.private.ts new file mode 100644 index 00000000..565006c1 --- /dev/null +++ b/apps/meseeks/convex/storage.private.ts @@ -0,0 +1,207 @@ +import { + CopyObjectCommand, + DeleteObjectCommand, + GetObjectCommand, + HeadObjectCommand, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { env } from 'schemas/envSchema'; + +const encoder = new TextEncoder(); + +const config = { + bucket: env.OBJECT_STORAGE_BUCKET, + rootPrefix: env.OBJECT_STORAGE_ROOT_PREFIX, +}; + +const client = new S3Client({ + region: env.OBJECT_STORAGE_REGION, + endpoint: env.OBJECT_STORAGE_ENDPOINT, + credentials: { + accessKeyId: env.OBJECT_STORAGE_ACCESS_KEY_ID, + secretAccessKey: env.OBJECT_STORAGE_SECRET_ACCESS_KEY, + }, +}); + +const uploadUrlTtlSeconds = 10 * 60; + +type BodyWithTransform = { + transformToString: () => Promise; +}; + +export async function storeText({ + content, + contentType, + owner, +}: { + content: string; + contentType: string; + owner: string; +}) { + // + const bytes = encoder.encode(content); + const hash = await sha256Hex(bytes); + const storageId = crypto.randomUUID(); + const storageKey = `${config.rootPrefix}/${owner}/${storageId}`; + + await client.send( + new PutObjectCommand({ + Bucket: config.bucket, + Key: storageKey, + Body: bytes, + ContentType: contentType, + }), + ); + + return { + hash, + size: bytes.byteLength, + storageKey, + }; +} + +export function createCanonicalStorageKey({ owner }: { owner: string }) { + // + return `${config.rootPrefix}/${owner}/${crypto.randomUUID()}`; +} + +export function createStagedStorageKey({ owner }: { owner: string }) { + // + return `${config.rootPrefix}/${owner}/.staged/${crypto.randomUUID()}`; +} + +export async function createUploadUrl({ + checksum, + contentType, + storageKey, +}: { + checksum: string; + contentType: string; + storageKey: string; +}) { + // + const expiresAt = Date.now() + uploadUrlTtlSeconds * 1000; + const uploadUrl = await getSignedUrl( + client, + new PutObjectCommand({ + Bucket: config.bucket, + Key: storageKey, + ContentType: contentType, + ChecksumSHA256: checksum, + }), + { expiresIn: uploadUrlTtlSeconds }, + ); + + return { uploadUrl, expiresAt }; +} + +export async function createReadUrl({ storageKey }: { storageKey: string }) { + // + const expiresAt = Date.now() + uploadUrlTtlSeconds * 1000; + const readUrl = await getSignedUrl( + client, + new GetObjectCommand({ + Bucket: config.bucket, + Key: storageKey, + }), + { expiresIn: uploadUrlTtlSeconds }, + ); + + return { readUrl, expiresAt }; +} + +export async function findBody({ storageKey }: { storageKey: string }) { + // + const response = await client.send( + new HeadObjectCommand({ + Bucket: config.bucket, + Key: storageKey, + ChecksumMode: 'ENABLED', + }), + ); + + return { + checksum: response.ChecksumSHA256, + contentLength: response.ContentLength, + contentType: response.ContentType, + }; +} + +export async function copyBody({ contentType, from, to }: { contentType: string; from: string; to: string }) { + // + const source = copySource(from); + const copyUrl = await getSignedUrl( + client, + new CopyObjectCommand({ + Bucket: config.bucket, + Key: to, + CopySource: source, + ContentType: contentType, + MetadataDirective: 'REPLACE', + }), + { expiresIn: uploadUrlTtlSeconds }, + ); + const response = await fetch(copyUrl, { + method: 'PUT', + headers: { + 'content-type': contentType, + 'x-amz-copy-source': source, + 'x-amz-metadata-directive': 'REPLACE', + }, + }); + + if (response.ok) return; + + throw new Error(`Object Storage copy failed with HTTP ${response.status}: ${await response.text()}`); +} + +export async function readText({ storageKey }: { storageKey: string }) { + // + const response = await client.send( + new GetObjectCommand({ + Bucket: config.bucket, + Key: storageKey, + }), + ); + + if (!isBodyWithTransform(response.Body)) throw new Error('Object Storage body is not readable as text.'); + + return await response.Body.transformToString(); +} + +export async function deleteBody({ storageKey }: { storageKey: string }) { + // + await client.send( + new DeleteObjectCommand({ + Bucket: config.bucket, + Key: storageKey, + }), + ); +} + +function copySource(storageKey: string) { + // + return `${config.bucket}/${storageKey.split('/').map(encodeURIComponent).join('/')}`; +} + +async function sha256Hex(bytes: Uint8Array) { + // + const safeBytes = new Uint8Array(bytes.byteLength); + safeBytes.set(bytes); + const digest = await crypto.subtle.digest('SHA-256', safeBytes); + + return Array.from(new Uint8Array(digest)) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); +} + +function isBodyWithTransform(body: unknown): body is BodyWithTransform { + // + if (!body) return false; + if (typeof body !== 'object') return false; + if (!('transformToString' in body)) return false; + + return typeof body.transformToString === 'function'; +} diff --git a/apps/meseeks/convex/subscriptions.private.ts b/apps/meseeks/convex/subscriptions.private.ts deleted file mode 100644 index e9d0b31a..00000000 --- a/apps/meseeks/convex/subscriptions.private.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { zid } from 'convex-helpers/server/zod3'; -import { z } from 'zod/v3'; -import { Doc } from './_generated/dataModel'; -import { defineMutation, defineQuery } from 'lib/convex'; -import { NotFound } from 'lib/errors'; -import { addSubscriptionCreditsTransaction } from './transactions.private'; -import { setUserIsFounder } from './users.private'; - -export const addSubscription = defineMutation({ - args: z.object({ - owner: zid('users'), - paymentUrl: z.string().url(), - paymentId: z.string(), - }), - handler: async (ctx, args) => { - // - return await ctx.db.insert('subscriptions', { - ...args, - status: 'pending' as const, - }); - }, -}); - -export const activateSubscription = defineMutation({ - args: z.object({ - checkoutId: z.string(), - months: z.number(), - credits: z.bigint(), - isFounder: z.boolean().optional(), - isRenewal: z.boolean().optional().default(false), - polarSubscriptionId: z.string().optional(), - }), - handler: async (ctx, args) => { - // - const { checkoutId, months, credits, isFounder, isRenewal, polarSubscriptionId } = args; - const subscription = await findSubscriptionByPaymentId(ctx, { paymentId: checkoutId }); - - if (!subscription) throw NotFound(); - if (!isRenewal && subscription.status !== 'pending') throw new Error('Subscription not pending'); - - const now = Date.now(); - const validUntil = now + months * 30 * 24 * 60 * 60 * 1000; // months to milliseconds - - const updateData: Partial> = { - status: 'active' as const, - isFounder: subscription.isFounder || isFounder, - validUntil, - }; - - if (isRenewal) { - updateData.renewalCount = (subscription.renewalCount ?? 0) + 1; - updateData.lastRenewalDate = now; - } - - if (polarSubscriptionId) { - updateData.polarSubscriptionId = polarSubscriptionId; - } - - await ctx.db.patch(subscription._id, updateData); - - if (isFounder) { - await setUserIsFounder(ctx, { userId: subscription.owner, isFounder: true }); - } - - if (credits > 0n) { - await addSubscriptionCreditsTransaction(ctx, { - owner: subscription.owner, - value: { symbol: 'USD', amount: credits }, - description: 'Subscription credits', - subscriptionId: subscription._id, - }); - } - }, -}); - -export const findSubscriptionByPaymentId = defineQuery({ - args: z.object({ - paymentId: z.string(), - }), - handler: async (ctx, { paymentId }) => { - // - return await ctx.db - .query('subscriptions') - .withIndex('by_paymentId', (q) => q.eq('paymentId', paymentId)) - .first(); - }, -}); - -export const findSubscription = defineQuery({ - args: z.object({ - subscriptionId: zid('subscriptions'), - }), - handler: async (ctx, { subscriptionId }) => { - // - return await ctx.db.get(subscriptionId); - }, -}); - -export const findActiveSubscriptions = defineQuery({ - args: z.object({ - owner: zid('users'), - }), - handler: async (ctx, { owner }): Promise[]> => { - // - const now = Date.now(); - - return await ctx.db - .query('subscriptions') - .withIndex('by_owner_status', (q) => q.eq('owner', owner).eq('status', 'active')) - .collect() - .then((subs) => subs.filter((s) => (s.validUntil ?? 0) > now)); - }, -}); - -export const findSubscriptionByPolarSubscriptionId = defineQuery({ - args: z.object({ - polarSubscriptionId: z.string(), - }), - handler: async (ctx, { polarSubscriptionId }) => { - // - return await ctx.db - .query('subscriptions') - .withIndex('by_polarSubscriptionId', (q) => q.eq('polarSubscriptionId', polarSubscriptionId)) - .first(); - }, -}); - -export const revokeSubscription = defineMutation({ - args: z.object({ - polarSubscriptionId: z.string(), - }), - handler: async (ctx, { polarSubscriptionId }) => { - // - const subscription = await findSubscriptionByPolarSubscriptionId(ctx, { polarSubscriptionId }); - - if (!subscription) { - throw NotFound(`Subscription not found for revocation: ${polarSubscriptionId}`); - } - - await ctx.db.patch(subscription._id, { - status: 'revoked', - validUntil: Date.now(), // Immediate revocation - }); - - console.debug('Subscription revoked immediately', { - subscriptionId: subscription._id, - polarSubscriptionId, - }); - }, -}); - -// export const handleRefund = async ( -// ctx: MutationCtx, // -// args: { -// checkoutId: string; -// amount: number; -// description?: string; -// }, -// ) => { -// // -// const { checkoutId, amount, description = 'Subscription refund' } = args; -// const subscription = await findOneByPaymentId(ctx, { paymentId: checkoutId }); - -// if (!subscription) { -// console.warn('Subscription not found for refund', { checkoutId }); -// return; -// } - -// // Add negative transaction for refund -// await ctx.runMutation(internal.transactions._addSubscriptionCredits, { -// owner: subscription.owner, -// value: { symbol: 'USD', amount: -BigInt(Math.round(amount * 100)) }, -// description, -// subscriptionId: subscription._id, -// }); - -// // If subscription is still active and refund is significant, consider canceling -// const refundThreshold = 5; // $5 threshold -// if (subscription.status === 'active' && amount >= refundThreshold) { -// await ctx.db.patch(subscription._id, { -// status: 'canceled', -// validUntil: Date.now(), -// }); -// } - -// console.debug('Subscription refund processed', { -// subscriptionId: subscription._id, -// amount, -// description, -// }); -// }; diff --git a/apps/meseeks/convex/subscriptions.ts b/apps/meseeks/convex/subscriptions.ts deleted file mode 100644 index 91b337a8..00000000 --- a/apps/meseeks/convex/subscriptions.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { Polar } from '@polar-sh/sdk'; -import { z } from 'zod/v3'; -import { action, internalMutation, internalQuery, query } from 'lib/convex'; -import { env } from 'schemas/envSchema'; -import { - addSubscription, - activateSubscription, - findActiveSubscriptions, - revokeSubscription, -} from './subscriptions.private'; -import { getCurrentUser, isProSubscriber } from './users.private'; -import { internal } from './_generated/api'; - -// called by startSubscription action after polar checkout creation to persist the pending subscription -export const _add = internalMutation({ - args: addSubscription.args.shape, - handler: addSubscription, -}); - -// called from lib/polar.ts when order.paid arrives to activate/renew the subscription -export const _activate = internalMutation({ - args: activateSubscription.args.shape, - handler: activateSubscription, -}); - -// called from lib/polar.ts on subscription.revoked webhook to revoke access immediately -export const _handleRevocation = internalMutation({ - args: revokeSubscription.args.shape, - handler: revokeSubscription, -}); - -export const _getStartSubscriptionContext = internalQuery({ - args: {}, - handler: async (ctx) => { - // - const currentUser = await getCurrentUser(ctx, {}); - const isCurrentUserProSubscriber = await isProSubscriber(ctx, { owner: currentUser._id }); - - return { currentUser, isProSubscriber: isCurrentUserProSubscriber }; - }, -}); - -export const startSubscription = action({ - args: { - product: z.enum(['pro', 'founder']), - }, - handler: async (ctx, { product }): Promise<{ paymentUrl: string }> => { - // - const { currentUser, isProSubscriber } = await ctx.runQuery( - internal.subscriptions._getStartSubscriptionContext, - {}, - ); - - if (isProSubscriber) throw new Error('User is already Pro.'); - - const polar = new Polar({ - server: env.POLAR_SERVER, - accessToken: env.POLAR_ACCESS_TOKEN, - }); - - const productId = product === 'founder' ? env.POLAR_FOUNDER_PACK_ID : env.POLAR_SUBSCRIPTION_ID; - - const checkout = await polar.checkouts.create({ - allowDiscountCodes: false, - successUrl: `${env.SITE_URL}/polar/subscribed?checkout_id={CHECKOUT_ID}`, - customerName: currentUser.name, - customerExternalId: currentUser._id, - customerEmail: currentUser.email, - products: [productId], - }); - - await ctx.runMutation(internal.subscriptions._add, { - owner: currentUser._id, - paymentUrl: checkout.url, - paymentId: checkout.id, - }); - - return { paymentUrl: checkout.url }; - }, -}); - -export const findActive = query({ - args: {}, - handler: async (ctx) => { - // - const currentUser = await getCurrentUser(ctx, {}); - - return await findActiveSubscriptions(ctx, { owner: currentUser._id }); - }, -}); diff --git a/apps/meseeks/convex/tasks.private.ts b/apps/meseeks/convex/tasks.private.ts deleted file mode 100644 index 6d38c20d..00000000 --- a/apps/meseeks/convex/tasks.private.ts +++ /dev/null @@ -1,624 +0,0 @@ -import { zid } from 'convex-helpers/server/zod3'; -import { z } from 'zod/v3'; -import type { Doc, Id } from './_generated/dataModel'; -import { addActions } from './action.private'; -import { defineMutation, defineQuery } from 'lib/convex'; -import { InsufficientAccountFunds, NotFound } from 'lib/errors'; -import { asBigInt, asDollars } from 'lib/money'; -import { cancelTaskSchedules } from './schedules.private'; -import { authorSchema } from 'schemas/authorSchema'; -import { intelligenceKeys } from 'schemas/intelligenceSchema'; -import { taskStatusSchema } from 'schemas/taskSchema'; -import { findEnabledSkillsWithDetails } from './skills.private'; -import { addTaskFundingTransaction, addTaskRefundTransaction } from './transactions.private'; -import { findUser, getCurrentUser } from './users.private'; - -type EnabledSkillDetail = { - key: string; - description: string; - inputSchema: string; -}; - -export const findTask = defineQuery({ - args: z.object({ - taskId: zid('tasks'), - }), - handler: async (ctx, { taskId }) => { - // - const task = await ctx.db.get(taskId); - if (!task) throw NotFound(); - - return task; - }, -}); - -// export const _findAllNotEmbedded = internalQuery({ -// args: {}, -// handler: async (ctx) => { -// // -// return await ctx.db -// .query('tasks') -// .withIndex('by_embeddingId', (q) => q.eq('embeddingId', undefined)) -// .collect(); -// }, -// }); - -// export const _findAllByEmbeddingIds = internalQuery({ -// args: { -// embeddings: z.array( -// z.object({ -// _id: zid('taskEmbeddings'), -// _score: z.number(), -// }), -// ), -// }, -// handler: async (ctx, { embeddings }) => { -// // -// const tasks = await Promise.all( -// embeddings.map(async ({ _id, _score }) => { -// const task = await ctx.db -// .query('tasks') -// .withIndex('by_embeddingId', (q) => q.eq('embeddingId', _id)) -// .unique(); - -// if (!task) return null; - -// return { -// ...task, -// description: undefined, // not sending description to avoid too much data -// _score, -// }; -// }), -// ); - -// return tasks.filter((task) => task !== null); -// }, -// }); - -export const findActiveTasks = defineQuery({ - args: z.object({ - owner: zid('users'), - limit: z.number().min(1).max(100).optional(), - }), - handler: async (ctx, { owner, limit }) => { - // - const query = ctx.db.query('tasks').withIndex('by_owner_isActive', (q) => - q - .eq('owner', owner) // - .eq('isActive', true), - ); - - // TODO: make sure higher budget tasks are first - return limit ? await query.take(limit) : await query.collect(); - }, -}); - -export const findAllAtInboxByOwner = defineQuery({ - args: z.object({ - owner: zid('users'), - }), - handler: async (ctx, { owner }) => { - // - const find = ({ isActive }: { isActive: boolean }) => - ctx.db - .query('tasks') - .withIndex('by_owner_parentId_isActive', (q) => - q - .eq('owner', owner) // - .eq('parentId', undefined) - .eq('isActive', isActive), - ) - .order('desc') - .collect(); - - const [active, inactive] = await Promise.all([ - find({ isActive: true }), // - find({ isActive: false }), - ]); - - return active.concat(inactive); - }, -}); - -export const ensureTaskOwner = defineQuery({ - args: z.object({ - taskId: zid('tasks'), - }), - handler: async ( - ctx, - { taskId }, - ): Promise<{ - currentUser: { _id: Id<'users'> }; - task: Doc<'tasks'>; - }> => { - // - const currentUser = await getCurrentUser(ctx, {}); - const task = await ctx.db.get(taskId); - - if (!task) throw NotFound(); - if (task.owner !== currentUser._id) throw NotFound(); // purposefully do not mention authorization - - return { currentUser, task }; - }, -}); - -export const addTask = defineMutation({ - args: z.object({ - author: authorSchema, - owner: zid('users'), - message: z.string().optional(), - parentId: zid('tasks').optional(), - preferredIntelligence: intelligenceKeys.optional(), - initialFunds: z - .bigint() - .min(0n) - .max(asBigInt({ dollars: 100000 })) - .optional(), - }), - handler: async (ctx, { author, owner, message, parentId, initialFunds, preferredIntelligence }) => { - // - const taskId = await ctx.db.insert('tasks', { - author, - owner, - parentId, - status: 'idle', - isActive: true, - energyBudget: { - total: 0n, - available: 0n, - }, - preferredIntelligence, - availableSkills: [], - }); - - // TODO: receive actions instead of using hardcoded ones - // also, should we have a `createTask` action? to make it explicit? - // instead: no explicit createTask(), you just act() and a new task is created if not provided - - const skills = [ - ...(initialFunds && initialFunds > 0n - ? [ - { - skillKey: 'increaseBudget', - args: { amount: initialFunds, shouldIterate: false }, - }, - ] - : []), - { - skillKey: 'say', - args: { message }, - }, - ]; - - await addActions(ctx, { - taskId, - author, - owner, - depth: 0, - skills, - }); - - return taskId; - }, -}); - -export const addTaskWithActions = defineMutation({ - args: z.object({ - author: authorSchema, - owner: zid('users'), - title: z.string().optional(), - instructions: z.string().optional(), - parentId: zid('tasks').optional(), - preferredIntelligence: intelligenceKeys.optional(), - skills: z.array( - z.object({ - skillKey: z.string().describe('The key of the skill to use'), - args: z.record(z.any()), - }), - ), - }), - handler: async (ctx, { author, owner, title, instructions, parentId, preferredIntelligence, skills }) => { - // - const taskId = await ctx.db.insert('tasks', { - author, - owner, - title, - instructions, - parentId, - status: 'idle', - isActive: true, - energyBudget: { - total: 0n, - available: 0n, - }, - preferredIntelligence, - availableSkills: [], - }); - - await addActions(ctx, { - taskId, - author, - owner, - depth: 0, - skills, - }); - - return taskId; - }, -}); - -// export const _semanticSearch = internalAction({ -// args: { -// query: z.string(), -// }, -// handler: async (ctx, { query }): Promise & { _score: number }>> => { -// // -// const { embedding, usage } = await embed({ -// model: openai.embedding('text-embedding-3-large'), -// value: query, -// }); - -// console.log('embedding usage', usage); - -// const results = await ctx.vectorSearch('taskEmbeddings', 'by_embedding', { -// vector: embedding, -// limit: 16, -// // filter: (q) => q.eq('isDone', false), -// }); - -// const tasks = await ctx.runQuery(internal.tasks._findAllByEmbeddingIds, { -// embeddings: results, -// }); - -// return tasks; -// }, -// }); - -// export const _addEmbedding = internalMutation({ -// args: { -// taskId: zid('tasks'), -// embedding: z.array(z.number()), -// isDone: z.boolean(), -// }, -// handler: async (ctx, { taskId, embedding, isDone }) => { -// // -// const embeddingId = await ctx.db.insert('taskEmbeddings', { taskId, embedding, isDone }); -// await ctx.db.patch(taskId, { embeddingId }); -// }, -// }); - -// export const _removeEmbedding = internalMutation({ -// args: { -// taskId: zid('tasks'), -// }, -// handler: async (ctx, { taskId }) => { -// // -// const task = await _findOne(ctx, { taskId }); -// if (!task.embeddingId) return; - -// await ctx.db.patch(taskId, { embeddingId: undefined }); -// await ctx.db.delete(task.embeddingId); -// }, -// }); - -// export const _embedTask = internalAction({ -// args: { -// taskId: zid('tasks'), -// }, -// handler: async (ctx, { taskId }) => { -// // -// const task = await ctx.runQuery(internal.tasks._findOne, { taskId }); - -// if (!task.instructions) return; - -// const { embedding, usage } = await embed({ -// model: openai.embedding('text-embedding-3-large'), -// value: task.instructions, -// }); - -// console.log('embedding usage', usage); - -// await ctx.runMutation(internal.tasks._addEmbedding, { -// taskId, -// embedding, -// status: task.status, -// }); -// }, -// }); - -// export const _embedAllMissingTasks = internalAction({ -// args: {}, -// handler: async (ctx) => { -// // -// const tasks = await ctx.runQuery(internal.tasks._findAllNotEmbedded); - -// for (const task of tasks) { -// await ctx.runAction(internal.tasks._embedTask, { taskId: task._id }); -// } -// }, -// }); - -export const updateTaskInstructions = defineMutation({ - args: z.object({ - taskId: zid('tasks'), - title: z.string().optional(), - instructions: z.string().optional(), - summary: z.string().optional(), - availableSkills: z.array(z.string()).max(16).optional(), - owner: zid('users'), - }), - handler: async (ctx, { taskId, title, instructions, summary, availableSkills, owner }) => { - // - if ( - title === undefined && - instructions === undefined && - summary === undefined && - availableSkills === undefined - ) { - throw new Error('Nothing to do'); - } - - // make sure the skills are valid - if (availableSkills) { - // - // get enabled skills - this is what the AI actually sees as available options - const enabledSkills: EnabledSkillDetail[] = await findEnabledSkillsWithDetails(ctx, { - userId: owner, - }); - - // create a set of enabled skill keys for fast lookup - const enabledSkillKeys: Set = new Set(enabledSkills.map((skill: EnabledSkillDetail) => skill.key)); - - // find invalid skills (skills that are not enabled for the user) - const validSkills = availableSkills.filter((skillKey) => enabledSkillKeys.has(skillKey)); - - if (validSkills.length !== availableSkills.length) { - const invalidSkills = availableSkills.filter((skillKey) => !enabledSkillKeys.has(skillKey)); - console.debug(`Invalid skills were selected: ${invalidSkills.join(', ')}. Ignored them.`); - availableSkills = validSkills; - } - } - - // TODO: we're only updating if not undefined, shouldn't we replace instead? - return await ctx.db.patch(taskId, { - ...(title !== undefined && { title }), - ...(instructions !== undefined && { instructions }), - ...(summary !== undefined && { summary }), - ...(availableSkills !== undefined && { availableSkills }), - lastUpdatedAt: Date.now(), - }); - }, -}); - -export const addTaskAvailableSkill = defineMutation({ - args: z.object({ - taskId: zid('tasks'), - skillKey: z.string(), - }), - handler: async (ctx, { taskId, skillKey }) => { - // - const task = await findTask(ctx, { taskId }); - if (!task) throw NotFound(); - - if (task.availableSkills?.includes(skillKey)) return; - - await ctx.db.patch(taskId, { - availableSkills: [...(task.availableSkills ?? []), skillKey], - }); - }, -}); - -export const markTaskAsRead = defineMutation({ - args: z.object({ - taskId: zid('tasks'), - }), - handler: async (ctx, { taskId }) => { - // - const task = await findTask(ctx, { taskId }); - - if (task.status === 'unread' || task.status === 'blocked') { - await setTaskStatus(ctx, { taskId, newStatus: 'idle' }); - } - }, -}); - -export const setTaskStatus = defineMutation({ - args: z.object({ - taskId: zid('tasks'), - newStatus: taskStatusSchema, - }), - handler: async (ctx, { taskId, newStatus }) => { - // - if (newStatus === 'done' || newStatus === 'discarded') { - // - const task = await findTask(ctx, { taskId }); - if (!task) throw NotFound(); - - // remove funds from the task - if (task.energyBudget.available > 0n) { - await removeTaskFunds(ctx, { - taskId, - amount: task.energyBudget.available, - }); - } - - // cancel all active schedules for this task - const cancelledCount = await cancelTaskSchedules(ctx, { taskId }); - if (cancelledCount > 0) { - console.debug(`Cancelled ${cancelledCount} schedule(s) for task ${taskId} (status: ${newStatus})`); - } - } - - return await ctx.db.patch(taskId, { - status: newStatus, - isActive: newStatus !== 'done' && newStatus !== 'discarded', - }); - }, -}); - -// export const _learn = internalAction({ -// args: { -// taskId: zid('tasks'), -// }, -// handler: async (ctx, { taskId }) => { -// // -// const task = await ctx.runQuery(internal.tasks._findOne, { taskId }); -// if (!task) throw new Error('Task not found'); - -// if (!task.resolution) { -// console.warn('Cannot learn from task without resolution', taskId); -// return false; -// } - -// // TODO: Implement learning logic here -// // This would typically involve: -// // 1. Extracting knowledge from the task and its resolution -// // 2. Storing this knowledge in a knowledge base -// // 3. Updating embeddings or other data structures for future reference - -// console.log('Learning from task', taskId); - -// // Re-embed the task with its resolution for better semantic search -// // await ctx.runAction(internal.tasks._embedTask, { taskId }); - -// return true; -// }, -// }); - -export const spendTaskFunds = defineMutation({ - args: z.object({ - taskId: zid('tasks'), - amount: z.bigint().min(0n), - }), - handler: async (ctx, { taskId, amount }) => { - // - const task = await findTask(ctx, { taskId }); - if (!task) throw NotFound(); - - console.debug(`using ${asDollars({ bigInt: amount })} from task ${taskId}`); - - if (task.energyBudget.available < amount) { - // - console.warn( - 'Insufficient funds on task', - taskId, - 'cost', - asDollars({ bigInt: amount }), - 'available', - asDollars({ bigInt: task.energyBudget.available }), - 'missing', - asDollars({ bigInt: amount - task.energyBudget.available }), - 'Will use all available funds', - ); - - amount = task.energyBudget.available; - } - - // update the task balance - await ctx.db.patch(taskId, { - energyBudget: { - total: task.energyBudget.total, - available: task.energyBudget.available - amount, - }, - }); - }, -}); - -export const increaseTaskBudget = defineMutation({ - args: z.object({ - taskId: zid('tasks'), - amount: z.bigint().min(0n), - }), - handler: async (ctx, { taskId, amount }) => { - // - const task = await findTask(ctx, { taskId }); - if (!task) throw NotFound(); - - const user = await findUser(ctx, { userId: task.owner }); - if (!user) throw NotFound(); - - const currentBalance = user.balanceUSD ?? 0n; - - console.debug( - 'increasing budget to task', - taskId, - asDollars({ bigInt: amount }), - 'current balance', - asDollars({ bigInt: currentBalance }), - ); - - if (currentBalance < amount) throw InsufficientAccountFunds(); - - // TODO: shouldn't this be an action? - // create the transaction - await addTaskFundingTransaction(ctx, { - taskId, - owner: task.owner, - value: { - symbol: 'USD', - amount: -amount, - }, - }); - - // update the task balance - await ctx.db.patch(taskId, { - energyBudget: { - total: task.energyBudget.total + amount, - available: task.energyBudget.available + amount, - }, - }); - }, -}); - -export const removeTaskFunds = defineMutation({ - args: z.object({ - taskId: zid('tasks'), - amount: z.bigint().min(0n), - }), - handler: async (ctx, { taskId, amount }) => { - // - const task = await findTask(ctx, { taskId }); - if (!task) throw NotFound(); - - // create the transaction - await addTaskRefundTransaction(ctx, { - taskId, - owner: task.owner, - value: { symbol: 'USD', amount }, - description: 'Refund of unused funds', - }); - - // update the task balance - await ctx.db.patch(taskId, { - energyBudget: { - total: task.energyBudget.total - amount, - available: task.energyBudget.available - amount, - }, - }); - }, -}); - -export const moveTask = defineMutation({ - args: z.object({ - taskId: zid('tasks'), - newParentId: zid('tasks').optional(), - }), - handler: async (ctx, { taskId, newParentId }) => { - // - return await ctx.db.patch(taskId, { parentId: newParentId }); - - // TODO: forbid adding to itself - // TODO: report to parents as well, old and new - }, -}); - -export const setTaskPreferredIntelligence = defineMutation({ - args: z.object({ - taskId: zid('tasks'), - preferredIntelligence: intelligenceKeys, - }), - handler: async (ctx, { taskId, preferredIntelligence }) => { - // - return await ctx.db.patch(taskId, { preferredIntelligence }); - }, -}); diff --git a/apps/meseeks/convex/tasks.ts b/apps/meseeks/convex/tasks.ts deleted file mode 100644 index 2d25fdf1..00000000 --- a/apps/meseeks/convex/tasks.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { zid } from 'convex-helpers/server/zod3'; -import { z } from 'zod/v3'; -import { internalMutation, internalQuery, mutation, query } from 'lib/convex'; -import { asBigInt } from 'lib/money'; -import { intelligenceKeys } from 'schemas/intelligenceSchema'; -import { paginationOptionsSchema } from 'schemas/paginationOptionsSchema'; -import { - addTask, - addTaskAvailableSkill, - ensureTaskOwner, - findActiveTasks, - findAllAtInboxByOwner, - findTask, - increaseTaskBudget, - markTaskAsRead, - moveTask, - removeTaskFunds, - setTaskPreferredIntelligence, - setTaskStatus, - updateTaskInstructions, -} from './tasks.private'; -import { getCurrentUser } from './users.private'; - -// used by magicRock.private.ts to expand {{activeTasks}} inside generated system instructions -export const _findActiveTasks = internalQuery({ - args: findActiveTasks.args.shape, - handler: findActiveTasks, -}); - -// called by skills/builtIn/updateInstructions.ts so the ai can patch title/instructions/summary/available skills -export const _updateInstructions = internalMutation({ - args: updateTaskInstructions.args.shape, - handler: updateTaskInstructions, -}); - -// called by skills/builtIn/createSkill.ts and skills/builtIn/updateSkill.ts to append newly enabled skills to a task -export const _addAvailableSkill = internalMutation({ - args: addTaskAvailableSkill.args.shape, - handler: addTaskAvailableSkill, -}); - -// called by builtIn skills resolve/discard/reopen to drive task lifecycle transitions -export const _setStatus = internalMutation({ - args: setTaskStatus.args.shape, - handler: setTaskStatus, -}); - -// called by skills/builtIn/increaseBudget.ts so the ai can fund a task from account balance -export const _increaseBudget = internalMutation({ - args: increaseTaskBudget.args.shape, - handler: increaseTaskBudget, -}); - -// called by skills/builtIn/decreaseBudget.ts to refund unused task budget back to account balance -export const _removeFunds = internalMutation({ - args: removeTaskFunds.args.shape, - handler: removeTaskFunds, -}); - -// called by skills/builtIn/moveTask.ts so the ai can move a task to another parent or inbox -export const _move = internalMutation({ - args: moveTask.args.shape, - handler: moveTask, -}); - -export const findAll = query({ - args: { - parentId: zid('tasks').optional(), - }, - handler: async (ctx, { parentId }) => { - // - if (!parentId) { - const currentUser = await getCurrentUser(ctx, {}); - return await findAllAtInboxByOwner(ctx, { owner: currentUser._id }); - } - - await ensureTaskOwner(ctx, { taskId: parentId }); - - const find = ({ isActive }: { isActive: boolean }) => - ctx.db - .query('tasks') - .withIndex('by_parent_isActive', (q) => - q - .eq('parentId', parentId) // - .eq('isActive', isActive), - ) - .order('desc') - .collect(); - - const [active, inactive] = await Promise.all([ - find({ isActive: true }), // - find({ isActive: false }), - ]); - - return active.concat(inactive); - }, -}); - -// sorted by available budget (descending, highest first) -export const findAllPaginated = query({ - args: { - paginationOpts: paginationOptionsSchema, - }, - handler: async (ctx, { paginationOpts }) => { - // - const currentUser = await getCurrentUser(ctx, {}); - - return await ctx.db - .query('tasks') - .withIndex('by_owner_energyAvailable', (q) => q.eq('owner', currentUser._id)) - .order('desc') - .paginate(paginationOpts); - }, -}); - -export const findAllAtInbox = query({ - args: {}, - handler: async (ctx) => { - // - const currentUser = await getCurrentUser(ctx, {}); - - return await findAllAtInboxByOwner(ctx, { owner: currentUser._id }); - }, -}); - -// paginated task list query for inbox roots or one parent's children -export const findAllAtInboxPaginated = query({ - args: { - parentId: zid('tasks').optional(), - paginationOpts: paginationOptionsSchema, - }, - handler: async (ctx, { parentId, paginationOpts }) => { - // - if (parentId) { - await ensureTaskOwner(ctx, { taskId: parentId }); - - return await ctx.db - .query('tasks') - .withIndex('by_parent_isActive', (q) => q.eq('parentId', parentId)) - .order('desc') - .paginate(paginationOpts); - } - - const currentUser = await getCurrentUser(ctx, {}); - - // inbox roots are keyed by owner + missing parent; the hook applies ui ordering - const results = await ctx.db - .query('tasks') - .withIndex('by_owner_parentId_isActive', (q) => q.eq('owner', currentUser._id).eq('parentId', undefined)) - .order('desc') - .paginate(paginationOpts); - - return results; - }, -}); - -export const findOne = query({ - args: { - taskId: zid('tasks'), - }, - handler: async (ctx, { taskId }) => { - // - await ensureTaskOwner(ctx, { taskId }); - return await findTask(ctx, { taskId }); - }, -}); - -export const findOneOrNot = query({ - args: { - taskId: zid('tasks').optional(), - }, - handler: async (ctx, { taskId }) => { - // - if (!taskId) return undefined; - - await ensureTaskOwner(ctx, { taskId }); - return await findTask(ctx, { taskId }); - }, -}); - -export const add = mutation({ - args: { - message: z.string().optional(), - parentId: zid('tasks').optional(), - preferredIntelligence: intelligenceKeys.optional(), - initialFunds: z - .bigint() - .min(0n) - .max(asBigInt({ dollars: 100000 })), - }, - handler: async (ctx, { message, parentId, initialFunds, preferredIntelligence }) => { - // - const currentUser = await getCurrentUser(ctx, {}); - return await addTask(ctx, { - author: currentUser._id, - owner: currentUser._id, - parentId, - message, - preferredIntelligence, - initialFunds, - }); - }, -}); - -export const markAsRead = mutation({ - args: { - taskId: zid('tasks'), - }, - handler: async (ctx, { taskId }) => { - // - await ensureTaskOwner(ctx, { taskId }); - await markTaskAsRead(ctx, { taskId }); - }, -}); - -export const setPreferredIntelligence = mutation({ - args: { - taskId: zid('tasks'), - preferredIntelligence: intelligenceKeys, - }, - handler: async (ctx, { taskId, preferredIntelligence }) => { - // - await ensureTaskOwner(ctx, { taskId }); - return await setTaskPreferredIntelligence(ctx, { taskId, preferredIntelligence }); - }, -}); diff --git a/apps/meseeks/convex/topUps.private.ts b/apps/meseeks/convex/topUps.private.ts index 1f174264..33d94972 100644 --- a/apps/meseeks/convex/topUps.private.ts +++ b/apps/meseeks/convex/topUps.private.ts @@ -3,14 +3,7 @@ import { z } from 'zod/v3'; import { defineMutation, defineQuery } from 'lib/convex'; import { NotFound } from 'lib/errors'; import { authorSchema } from 'schemas/authorSchema'; -import { polarEventSchema } from 'schemas/polarEventSchema'; -import { - blockchainSchema, - tokenSchema, - topUpAmountSchema, - topUpStatusSchema, - walletAddressSchema, -} from 'schemas/topUpSchema'; +import { topUpAmountSchema, topUpStatusSchema } from 'schemas/topUpSchema'; import { addTopUpTransaction } from './transactions.private'; export const findTopUpByPaymentId = defineQuery({ @@ -20,7 +13,7 @@ export const findTopUpByPaymentId = defineQuery({ handler: async (ctx, { paymentId }) => { // return await ctx.db - .query('topUps') + .query('top_ups') .withIndex('by_paymentId', (q) => q.eq('paymentId', paymentId)) .first(); }, @@ -30,30 +23,25 @@ export const addTopUp = defineMutation({ args: z.object({ owner: zid('users'), author: authorSchema, - to: walletAddressSchema, - description: z.string(), - chain: blockchainSchema, - symbol: tokenSchema, amount: topUpAmountSchema, + fee: z.bigint(), + totalCharged: z.bigint(), paymentUrl: z.string().url(), paymentId: z.string(), }), - handler: async (ctx, { author, owner, to, description, chain, symbol, amount, paymentUrl, paymentId }) => { + handler: async (ctx, args) => { // - const topUpId = await ctx.db.insert('topUps', { - to, - description, - chain, - symbol, - amount, + return await ctx.db.insert('top_ups', { + owner: args.owner, + author: args.author, + amount: args.amount, + fee: args.fee, + totalCharged: args.totalCharged, status: 'waiting', - author, - owner, - paymentUrl, - paymentId, + paymentUrl: args.paymentUrl, + paymentId: args.paymentId, + provider: 'polar', }); - - return topUpId; }, }); @@ -66,37 +54,21 @@ export const finishTopUp = defineMutation({ // const topUp = await findTopUpByPaymentId(ctx, { paymentId: checkoutId }); if (!topUp) throw NotFound(); - if (topUp.status !== 'waiting') throw new Error('Top up is not waiting'); await ctx.db.patch(topUp._id, { status: 'confirmed' }); - await addTopUpTransaction(ctx, { - topUpId: topUp._id, owner: topUp.owner, - value: { - symbol: topUp.symbol, - amount, - }, + value: amount, + topUp: topUp._id, + description: 'Top up', }); }, }); -export const persistPolarEvent = defineMutation({ - args: z.object({ - polarEvent: polarEventSchema, - }), - handler: async (ctx, { polarEvent }) => { - // - await ctx.db.insert('polarEvents', polarEvent); - }, -}); - -// TODO: add automatic timeout for waiting top ups - export const findTopUp = defineQuery({ args: z.object({ - topUpId: zid('topUps'), + topUpId: zid('top_ups'), }), handler: async (ctx, { topUpId }) => { return await ctx.db.get(topUpId); @@ -109,7 +81,7 @@ export const findWaitingTopUps = defineQuery({ }), handler: async (ctx, { owner }) => { return await ctx.db - .query('topUps') + .query('top_ups') .withIndex('by_status_owner', (q) => q .eq('status', 'waiting') // @@ -126,7 +98,7 @@ export const findTopUpsByStatus = defineQuery({ }), handler: async (ctx, { owner, status }) => { return await ctx.db - .query('topUps') + .query('top_ups') .withIndex('by_status_owner', (q) => q .eq('status', status) // diff --git a/apps/meseeks/convex/topUps.ts b/apps/meseeks/convex/topUps.ts index c2886618..04cb2af5 100644 --- a/apps/meseeks/convex/topUps.ts +++ b/apps/meseeks/convex/topUps.ts @@ -1,22 +1,16 @@ import { Polar } from '@polar-sh/sdk'; import { zid } from 'convex-helpers/server/zod3'; -import { z } from 'zod/v3'; -import { Id } from './_generated/dataModel'; -import { action, internalMutation, internalQuery, mutation, query } from 'lib/convex'; +import type { Id } from './_generated/dataModel'; +import { action, internalMutation, mutation, query } from 'lib/convex'; import { NotFound } from 'lib/errors'; -import { asDollars, asNumber } from 'lib/money'; +import { asNumber } from 'lib/money'; import { env } from 'schemas/envSchema'; -import { blockchainSchema, tokenSchema, topUpAmountSchema } from 'schemas/topUpSchema'; -import { internal } from './_generated/api'; -import { - addTopUp, - findTopUp, - findTopUpsByStatus, - findWaitingTopUps, - finishTopUp, - persistPolarEvent, -} from './topUps.private'; -import { getCurrentUser, isProSubscriber } from './users.private'; +import { topUpAmountSchema } from 'schemas/topUpSchema'; +import { api, internal } from './_generated/api'; +import { addTopUp, findTopUp, findTopUpsByStatus, findWaitingTopUps, finishTopUp } from './topUps.private'; +import { getCurrentUser } from './users.private'; + +const FEE_RATE_PERCENT = 2n; // called by startTopUp action after checkout creation to persist a waiting top-up record export const _add = internalMutation({ @@ -24,39 +18,21 @@ export const _add = internalMutation({ handler: addTopUp, }); -// called from lib/polar.ts on order.paid webhook to mark top-up confirmed and credit balance +// called from lib/polar.ts on order.paid webhook to mark top-up confirmed and credit energy export const _finish = internalMutation({ args: finishTopUp.args.shape, handler: finishTopUp, }); -// called from lib/polar.ts to persist raw webhook events for audit/debug before branching logic -export const _persistPolarEvent = internalMutation({ - args: persistPolarEvent.args.shape, - handler: persistPolarEvent, -}); - -export const _getStartTopUpContext = internalQuery({ - args: {}, - handler: async (ctx) => { - // - const currentUser = await getCurrentUser(ctx, {}); - const isCurrentUserProSubscriber = await isProSubscriber(ctx, { owner: currentUser._id }); - return { currentUser, isProSubscriber: isCurrentUserProSubscriber }; - }, -}); - export const startTopUp = action({ args: { - chain: blockchainSchema, - symbol: tokenSchema, amount: topUpAmountSchema, - description: z.string().optional(), }, - handler: async (ctx, { chain, symbol, amount, description }): Promise> => { + handler: async (ctx, { amount }): Promise> => { // - const { currentUser, isProSubscriber } = await ctx.runQuery(internal.topUps._getStartTopUpContext, {}); - if (!isProSubscriber) throw new Error('User is not Pro.'); + const currentUser = await ctx.runQuery(api.users.current, {}); + const fee = (amount * FEE_RATE_PERCENT) / 100n; + const totalCharged = amount + fee; console.debug(`Starting top up at Polar '${env.POLAR_SERVER}' environment.`); @@ -66,7 +42,7 @@ export const startTopUp = action({ }); const checkout = await polar.checkouts.create({ - amount: asNumber({ bigInt: amount }) * 100, // Polar requires USD cents ¢ + amount: asNumber({ bigInt: totalCharged }) * 100, allowDiscountCodes: false, successUrl: `${env.SITE_URL}/polar/topped?checkout_id={CHECKOUT_ID}`, customerName: currentUser.name, @@ -75,25 +51,21 @@ export const startTopUp = action({ products: [env.POLAR_TOP_UP_ID], }); - const topUpId = await ctx.runMutation(internal.topUps._add, { + return await ctx.runMutation(internal.topUps._add, { author: currentUser._id, owner: currentUser._id, - to: env.PAYMENT_ETH_ADDRESS_BASE_CHAIN, - description: description || `Add ${asDollars({ bigInt: amount })} energy to your account balance.`, - chain, - symbol, amount, + fee, + totalCharged, paymentUrl: checkout.url, paymentId: checkout.id, }); - - return topUpId; }, }); export const discard = mutation({ args: { - topUpId: zid('topUps'), + topUpId: zid('top_ups'), }, handler: async (ctx, { topUpId }) => { // @@ -102,8 +74,7 @@ export const discard = mutation({ if (!topUp) throw NotFound(); if (topUp.owner !== currentUser._id) throw NotFound(); - - if (topUp.status !== 'waiting') throw new Error('TopUp cannot be discarded anymore'); + if (topUp.status !== 'waiting') throw new Error('Top-up cannot be discarded anymore'); return await ctx.db.patch(topUpId, { status: 'discarded by user' }); }, @@ -111,7 +82,7 @@ export const discard = mutation({ export const findOne = query({ args: { - topUpId: zid('topUps'), + topUpId: zid('top_ups'), }, handler: async (ctx, { topUpId }) => { // diff --git a/apps/meseeks/convex/transactions.private.ts b/apps/meseeks/convex/transactions.private.ts index 112d69cb..a9b369d8 100644 --- a/apps/meseeks/convex/transactions.private.ts +++ b/apps/meseeks/convex/transactions.private.ts @@ -1,59 +1,58 @@ -import { zid } from 'convex-helpers/server/zod3'; import { z } from 'zod/v3'; import { defineMutation } from 'lib/convex'; -import { transactionSchema, valueSchema } from 'schemas/transactionSchema'; +import { transactionSchema } from 'schemas/transactionSchema'; import { adjustUserBalance } from './users.private'; export const addFreeCredits = defineMutation({ args: z.object({ - value: valueSchema, - owner: zid('users'), + owner: transactionSchema.shape.owner, + value: transactionSchema.shape.value, description: z.string(), }), - handler: async (ctx, args) => addTransaction(ctx, { kind: 'free credits', ...args }), + handler: async (ctx, args) => addTransaction(ctx, { kind: 'free energy', ...args }), }); export const addTopUpTransaction = defineMutation({ args: z.object({ - value: valueSchema, - topUpId: zid('topUps'), - owner: zid('users'), - description: z.string().optional(), + owner: transactionSchema.shape.owner, + value: transactionSchema.shape.value, + topUp: transactionSchema.shape.topUp.unwrap(), + description: transactionSchema.shape.description, }), handler: async (ctx, args) => addTransaction(ctx, { kind: 'top up', ...args }), }); -export const addTaskFundingTransaction = defineMutation({ +export const addActionCostTransaction = defineMutation({ args: z.object({ - value: valueSchema, - taskId: zid('tasks'), - owner: zid('users'), - description: z.string().optional(), + owner: transactionSchema.shape.owner, + value: transactionSchema.shape.value, + action: transactionSchema.shape.action.unwrap(), + description: transactionSchema.shape.description, }), - handler: async (ctx, args) => addTransaction(ctx, { kind: 'fund task', ...args }), + handler: async (ctx, args) => addTransaction(ctx, { kind: 'action cost', ...args }), }); -export const addTaskRefundTransaction = defineMutation({ +export const addStorageCostTransaction = defineMutation({ args: z.object({ - value: valueSchema, - taskId: zid('tasks'), - owner: zid('users'), - description: z.string().optional(), + owner: transactionSchema.shape.owner, + value: transactionSchema.shape.value, + file: transactionSchema.shape.file.unwrap(), + description: transactionSchema.shape.description, }), - handler: async (ctx, args) => addTransaction(ctx, { kind: 'refund from task', ...args }), + handler: async (ctx, args) => addTransaction(ctx, { kind: 'storage cost', ...args }), }); -export const addSubscriptionCreditsTransaction = defineMutation({ +export const addRefundTransaction = defineMutation({ args: z.object({ - value: valueSchema, - subscriptionId: zid('subscriptions'), - owner: zid('users'), - description: z.string().optional(), + owner: transactionSchema.shape.owner, + value: transactionSchema.shape.value, + action: transactionSchema.shape.action, + topUp: transactionSchema.shape.topUp, + description: transactionSchema.shape.description, }), - handler: async (ctx, args) => addTransaction(ctx, { kind: 'subscription', ...args }), + handler: async (ctx, args) => addTransaction(ctx, { kind: 'refund', ...args }), }); -// helper, not exported const addTransaction = defineMutation({ args: transactionSchema, handler: async (ctx, transaction) => { @@ -62,7 +61,7 @@ const addTransaction = defineMutation({ await adjustUserBalance(ctx, { userId: transaction.owner, - value: transaction.value, + amount: transaction.value, }); return transactionId; diff --git a/apps/meseeks/convex/transactions.ts b/apps/meseeks/convex/transactions.ts deleted file mode 100644 index d10fb1df..00000000 --- a/apps/meseeks/convex/transactions.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { z } from 'zod/v3'; -import { query } from 'lib/convex'; -import { paginationOptionsSchema } from 'schemas/paginationOptionsSchema'; -import { getCurrentUser } from './users.private'; - -export const findAll = query({ - args: {}, - handler: async (ctx) => { - // - const currentUser = await getCurrentUser(ctx, {}); - - return await ctx.db - .query('transactions') - .withIndex('by_owner', (q) => q.eq('owner', currentUser._id)) - .order('desc') - .collect(); - }, -}); - -export const findAllPaginated = query({ - args: { - paginationOpts: paginationOptionsSchema, - search: z.string().optional(), - }, - handler: async (ctx, { paginationOpts, search }) => { - // - const currentUser = await getCurrentUser(ctx, {}); - - // Use search index if search term is provided - if (search && search.trim()) { - return await ctx.db - .query('transactions') - .withSearchIndex('search_transactions', (q) => - q.search('description', search.trim()).eq('owner', currentUser._id), - ) - .paginate(paginationOpts); - } - - // Default query without search - return await ctx.db - .query('transactions') - .withIndex('by_owner', (q) => q.eq('owner', currentUser._id)) - .order('desc') - .paginate(paginationOpts); - }, -}); diff --git a/apps/meseeks/convex/triggers.private.ts b/apps/meseeks/convex/triggers.private.ts new file mode 100644 index 00000000..835dad31 --- /dev/null +++ b/apps/meseeks/convex/triggers.private.ts @@ -0,0 +1,319 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import type { Doc, Id } from 'convex/_generated/dataModel'; +import type { MutationCtx, QueryCtx } from 'convex/_generated/server'; +import { defineQuery } from 'lib/convex'; +import { NotFound } from 'lib/errors'; +import { newActionSchema } from 'schemas/actionSchema'; +import { fileRevisionChangeKindSchema } from 'schemas/fileRevisionSchema'; +import { enqueueAction } from './actions.private'; +import { recordActionDetail } from './action/details.private'; +import { ensureDirectoryOwner } from './files.private'; + +type MutationTrigger = Extract, { kind: 'mutation' }>; + +export const findTriggersByRoot = defineQuery({ + args: z.object({ + owner: zid('users'), + root: zid('files'), + }), + handler: async (ctx, { owner, root }) => { + // + await ensureDirectoryOwner(ctx, { + owner, + directory: root, + }); + + return await ctx.db + .query('triggers') + .withIndex('by_owner_root', (q) => + q + .eq('owner', owner) // + .eq('root', root), + ) + .collect(); + }, +}); + +export async function replaceCompiledMutationTriggersForRoot( + ctx: MutationCtx, + args: { + owner: Id<'users'>; + root: Id<'files'>; + action: Id<'actions'>; + triggers: Array<{ + events: Array>; + pattern?: string; + reactions: Array>; + maxUses?: number; + sourceFile: Id<'files'>; + sourcePath: string; + sourceHash?: string; + }>; + }, +) { + // + await ensureDirectoryOwner(ctx, { + owner: args.owner, + directory: args.root, + }); + + const existing = await ctx.db + .query('triggers') + .withIndex('by_owner_root', (q) => + q + .eq('owner', args.owner) // + .eq('root', args.root), + ) + .collect(); + const existingCompiled = existing.filter( + (trigger) => trigger.sourcePath?.startsWith('/.pro/triggers/') || trigger.sourcePath?.startsWith('/triggers/'), + ); + const existingByKey = new Map(existingCompiled.map((trigger) => [compiledTriggerKey(trigger), trigger])); + const nextKeys = new Set(args.triggers.map((trigger) => compiledTriggerKey(trigger))); + const compiledAt = Date.now(); + + for (const trigger of args.triggers) { + const row = existingByKey.get(compiledTriggerKey(trigger)); + let remainingUses = trigger.maxUses; + if (row && row.sourceHash === trigger.sourceHash && row.maxUses === trigger.maxUses) { + remainingUses = row.remainingUses; + } + const next = { + owner: args.owner, + root: args.root, + author: args.action, + kind: 'mutation' as const, + status: 'enabled' as const, + runCount: row?.runCount ?? 0, + lastRunAt: row?.lastRunAt, + lastError: undefined, + events: trigger.events, + pattern: trigger.pattern, + reactions: trigger.reactions, + maxUses: trigger.maxUses, + remainingUses, + sourceFile: trigger.sourceFile, + sourcePath: trigger.sourcePath, + sourceHash: trigger.sourceHash, + compiledBy: args.action, + compiledAt, + }; + + if (row) { + await ctx.db.patch(row._id, next); + continue; + } + + // compile is the only trigger-row creation path; trigger source files are canonical. + await ctx.db.insert('triggers', next); + } + + for (const trigger of existingCompiled) { + if (nextKeys.has(compiledTriggerKey(trigger))) continue; + + await ctx.db.delete(trigger._id); + } +} + +export async function disableTriggerForAction( + ctx: MutationCtx, + { owner, trigger }: { owner: Id<'users'>; trigger: Id<'triggers'> }, +) { + // + const row = await ctx.db.get(trigger); + if (!row) throw NotFound(); + if (row.owner !== owner) throw NotFound(); + + await ctx.db.patch(trigger, { status: 'disabled' }); + + return trigger; +} + +export async function scheduleMutationTriggerReactions( + ctx: MutationCtx, + { + action, + revisions, + }: { + action: Doc<'actions'>; + revisions: Array>; + }, +) { + // + if (action.author !== action.owner) return []; + + const visibleRevisions = revisions.filter( + (revision) => !isActionOutputPath(revision.afterPath ?? revision.beforePath), + ); + if (visibleRevisions.length === 0) return []; + + const triggers = await findEnabledMutationTriggersForTree(ctx, { + owner: action.owner, + root: action.root, + }); + const spark = action.spark === 'self' ? action._id : action.spark; + const roots = new Set>(); + + for (const trigger of triggers) { + const matched = visibleRevisions.filter((revision) => triggerMatchesRevision(trigger, revision)); + if (matched.length === 0) continue; + + const acceptedActions: Array> = []; + const matchedRevisions = matched.map((revision) => revision._id); + const matchedPaths = matched.flatMap((revision) => { + const path = revision.afterPath ?? revision.beforePath; + if (!path) return []; + + return [path]; + }); + const source = triggerReceiptSource(trigger); + for (const reaction of trigger.reactions) { + const reactionAction = await enqueueAction(ctx, { + owner: action.owner, + root: trigger.root, + author: action._id, + spark, + skill: reaction.skill, + input: reaction.input, + }); + acceptedActions.push(reactionAction); + await recordActionDetail(ctx, { + detail: { + owner: action.owner, + action: reactionAction, + createdAt: Date.now(), + kind: 'trigger', + trigger: trigger._id, + ...source, + sourceAction: action._id, + matchedRevisions, + matchedPaths, + proposals: [reaction], + }, + }); + } + if (acceptedActions.length > 0) roots.add(trigger.root); + + const remainingUses = trigger.maxUses ? (trigger.remainingUses ?? trigger.maxUses) - 1 : undefined; + + await ctx.db.patch(trigger._id, { + lastRunAt: Date.now(), + runCount: (trigger.runCount ?? 0) + 1, + lastError: undefined, + remainingUses, + }); + + await recordActionDetail(ctx, { + detail: { + owner: action.owner, + action: action._id, + createdAt: Date.now(), + kind: 'trigger', + trigger: trigger._id, + ...source, + matchedRevisions, + matchedPaths, + proposals: trigger.reactions, + acceptedActions, + }, + }); + } + + return Array.from(roots); +} + +async function findEnabledMutationTriggersForTree( + ctx: QueryCtx | MutationCtx, + { owner, root }: { owner: Id<'users'>; root: Id<'files'> }, +) { + // + const roots = await listDirectoryAncestors(ctx, { owner, root }); + const triggers = []; + for (const directory of roots) { + triggers.push( + ...(await ctx.db + .query('triggers') + .withIndex('by_root_kind_status', (q) => + q + .eq('root', directory) // + .eq('kind', 'mutation') + .eq('status', 'enabled'), + ) + .collect()), + ); + } + + return triggers.filter((trigger): trigger is MutationTrigger => { + if (trigger.kind !== 'mutation') return false; + if (trigger.remainingUses === 0) return false; + + return trigger.owner === owner; + }); +} + +async function listDirectoryAncestors( + ctx: QueryCtx | MutationCtx, + { owner, root }: { owner: Id<'users'>; root: Id<'files'> }, +) { + // + const roots: Array> = []; + let current: Doc<'files'> | null = await ctx.db.get(root); + if (!current) throw NotFound(); + + while (current) { + if (current.owner !== owner) throw NotFound(); + if (current.kind !== 'directory') throw NotFound(); + roots.push(current._id); + if (current.parent === 'root') break; + current = await ctx.db.get(current.parent); + } + + return roots; +} + +function triggerMatchesRevision(trigger: Doc<'triggers'>, revision: Doc<'file_revisions'>) { + // + if (trigger.kind !== 'mutation') return false; + if (!trigger.events.includes(revision.changeKind)) return false; + + const path = revision.afterPath ?? revision.beforePath ?? ''; + + return matchesPattern(path, trigger.pattern); +} + +function matchesPattern(path: string, pattern: string | undefined) { + // + const trimmed = pattern?.trim(); + if (!trimmed) return true; + if (!trimmed.includes('*')) return path.includes(trimmed); + + const escaped = trimmed + .split('*') + .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('.*'); + const regex = new RegExp(`^${escaped}$`); + + return regex.test(path); +} + +function compiledTriggerKey({ pattern, sourcePath }: { pattern?: string; sourcePath?: string }) { + // + return `${sourcePath ?? ''}\n${pattern ?? ''}`; +} + +function triggerReceiptSource(trigger: MutationTrigger) { + // + return { + ...(trigger.sourceFile ? { sourceFile: trigger.sourceFile } : {}), + ...(trigger.sourcePath ? { sourcePath: trigger.sourcePath } : {}), + ...(trigger.sourceHash ? { sourceHash: trigger.sourceHash } : {}), + ...(trigger.compiledBy ? { compiledBy: trigger.compiledBy } : {}), + ...(trigger.compiledAt ? { compiledAt: trigger.compiledAt } : {}), + }; +} + +function isActionOutputPath(path: string | undefined) { + // + return path === '/.pro/actions' || Boolean(path?.startsWith('/.pro/actions/')); +} diff --git a/apps/meseeks/convex/triggers.ts b/apps/meseeks/convex/triggers.ts new file mode 100644 index 00000000..afb23ac8 --- /dev/null +++ b/apps/meseeks/convex/triggers.ts @@ -0,0 +1,24 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { query } from 'lib/convex'; +import { ensureScopeOwner } from './files.private'; +import { findTriggersByRoot } from './triggers.private'; +import { getCurrentUser } from './users.private'; + +export const findByRoot = query({ + args: { + root: zid('files'), + }, + handler: async (ctx, { root }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + const actionRoot = await ensureScopeOwner(ctx, { + owner: currentUser._id, + directory: root, + }); + + return await findTriggersByRoot(ctx, { + owner: currentUser._id, + root: actionRoot._id, + }); + }, +}); diff --git a/apps/meseeks/convex/users.private.ts b/apps/meseeks/convex/users.private.ts index 5b1a54d7..86a44c34 100644 --- a/apps/meseeks/convex/users.private.ts +++ b/apps/meseeks/convex/users.private.ts @@ -2,156 +2,34 @@ import { zid } from 'convex-helpers/server/zod3'; import { z } from 'zod/v3'; import { defineMutation, defineQuery } from 'lib/convex'; import { NotFound, Unauthorized } from 'lib/errors'; -import { asBigInt } from 'lib/money'; -import { tokenSchema } from 'schemas/topUpSchema'; -import { findActiveSubscriptions } from './subscriptions.private'; -import { addTaskWithActions } from './tasks.private'; -import { addFreeCredits } from './transactions.private'; -import { setUserPreference } from './users/preferences.private'; import { components } from './_generated/api'; +import { ensureUserRootDirectory } from './files.private'; -const addInitialTask = defineMutation({ +export const ensureUserRoot = defineMutation({ args: z.object({ userId: zid('users'), }), handler: async (ctx, { userId }) => { // - const initialTaskId = await addTaskWithActions(ctx, { - author: userId, - owner: userId, - title: 'Look at me!', - instructions: `I want want to learn about Meseeks, so you can provide me with the best assistance possible. Please collect information about me through our conversation and store it using the setUserInfo skill. - -I'd like you to gather details such as: -- My name and background -- Where I'm from (birth place, where I grew up, current location) -- My citizenship/nationality -- My profession and interests -- Languages I speak and proficiency levels -- My social media handles -- Any other personal information I share that might be helpful for future interactions - -Please update my user information each time you learn something new about me, and make sure to never remove information that is still valid when adding new details. Write everything from my perspective, as if I'm describing myself. - -I'm also curious about Meseeks and would love to learn more about its capabilities, features, and how it can help me. Feel free to encourage me to ask questions about what Meseeks can do, how it works, or any other aspects I might be interested in exploring.`, - skills: [ - { - skillKey: 'increaseBudget', - args: { - amount: asBigInt({ dollars: 1 }), - shouldIterate: false, - }, - }, - { - skillKey: 'lookAtMe', - args: {}, - }, - ], - }); - - await ctx.db.patch(userId, { initialTaskId }); - - return initialTaskId; - }, -}); - -const setDefaultPreferences = defineMutation({ - args: z.object({ - userId: zid('users'), - }), - handler: async (ctx, { userId }) => { - // - // TODO: unhack - const defaultEnabledSkills = [ - 'searchWeb', - 'valyu_search', - 'github_search', - 'twitter_search', - 'searchIdealista', - 'scrapeLink', - 'scrapeTweet', - 'searchPlaces', - 'analyze', - 'compose', - 'transcribeYouTube', - 'describeYouTube', - ]; - - // TODO: this also brings idealista_* (inner skills) - // const proSkills = await _findAllByOwner(ctx, { owner: 'isPro' }); - // const defaultEnabledSkills = proSkills.map((skill) => skill.key); - - await setUserPreference(ctx, { - userId, - key: 'enabledSkills', - value: defaultEnabledSkills, - }); - }, -}); - -export const seedUserIfNeeded = defineMutation({ - args: z.object({ - userId: zid('users'), - }), - handler: async (ctx, { userId }) => { - // - const user = await findUser(ctx, { userId }); - if (user?.isReady) return; - - console.info('new user!', userId); - - // const isVerified = user?.verificationLevel === 'orb'; - - await addFreeCredits(ctx, { - owner: userId, - value: { - symbol: 'USD', - amount: asBigInt({ dollars: 2 }), - }, - description: 'Welcome credits', - }); - - // TODO: users should be able to spawn their own Convex instance for full isolation and control - - // await addInitialTask(ctx, { userId }); - await setDefaultPreferences(ctx, { userId }); - - await ctx.db.patch(userId, { isReady: true }); + return await ensureUserRootDirectory(ctx, { owner: userId }); }, }); export const adjustUserBalance = defineMutation({ args: z.object({ userId: zid('users'), - value: z.object({ - symbol: tokenSchema, - amount: z.bigint(), - }), + amount: z.bigint(), }), - handler: async (ctx, { userId, value }) => { + handler: async (ctx, { userId, amount }) => { // const user = await findUser(ctx, { userId }); if (!user) throw NotFound(); - console.debug('adjust account balance', userId, value.amount); - - if (value.symbol !== 'USD') throw new Error('Only USD is supported for now'); - - return await ctx.db.patch(userId, { balanceUSD: (user.balanceUSD ?? 0n) + value.amount }); - }, -}); + console.debug('adjust account balance', userId, amount); -export const setUserIsFounder = defineMutation({ - args: z.object({ - userId: zid('users'), - isFounder: z.boolean(), - }), - handler: async (ctx, { userId, isFounder }) => { - // - const user = await findUser(ctx, { userId }); - if (!user) throw NotFound(); + const energyBalance = user.energyBalance ?? 0n; - await ctx.db.patch(userId, { isFounder }); + return await ctx.db.patch(userId, { energyBalance: energyBalance + amount }); }, }); @@ -195,6 +73,7 @@ const addUserArgs = z.object({ email: z.string().min(1), name: z.string().optional(), image: z.string().optional(), + isAnonymous: z.boolean().optional(), }); const updateExistingUserArgs = addUserArgs.extend({ @@ -208,7 +87,7 @@ export const addUser = defineMutation({ const { authUserId, email } = authUser; const linkedUser = await findUserByAuthUserId(ctx, { authUserId }); - if (linkedUser) return await patchUser(ctx, { ...authUser, userId: linkedUser._id }); + if (linkedUser) return await syncAuthUser(ctx, { ...authUser, userId: linkedUser._id }); const userWithEmail = await findUserByEmail(ctx, { email }); if (!userWithEmail) return await createUser(ctx, authUser); @@ -223,7 +102,7 @@ export const addUser = defineMutation({ throw new Error(`Could not safely link auth user for ${email}`); } - return await linkUser(ctx, { ...authUser, userId: userWithEmail._id }); + return await syncAuthUser(ctx, { ...authUser, userId: userWithEmail._id }); }, }); @@ -234,9 +113,9 @@ export const updateUser = defineMutation({ const { authUserId } = authUser; const user = await findUserByAuthUserId(ctx, { authUserId }); - if (!user) return addUser(ctx, authUser); + if (!user) return await addUser(ctx, authUser); - return await patchUser(ctx, { ...authUser, userId: user._id }); + return await syncAuthUser(ctx, { ...authUser, userId: user._id }); }, }); @@ -268,18 +147,6 @@ export const getCurrentUser = defineQuery({ }, }); -export const isProSubscriber = defineQuery({ - args: z.object({ - owner: zid('users'), - }), - handler: async (ctx, { owner }) => { - // - const activeSubscriptions = await findActiveSubscriptions(ctx, { owner }); - - return activeSubscriptions.length > 0; - }, -}); - const setAuthUserAppUserId = defineMutation({ args: z.object({ authUserId: z.string().min(1), @@ -301,50 +168,30 @@ const createUser = defineMutation({ args: addUserArgs, handler: async (ctx, authUser) => { // - const { authUserId, email, name, image } = authUser; + const { authUserId, email, name, image, isAnonymous } = authUser; const userId = await ctx.db.insert('users', { authUserId, email, name, image, - isReady: false, - balanceUSD: 0n, - isFounder: false, + isAnonymous: Boolean(isAnonymous), + energyBalance: 0n, }); - await setAuthUserAppUserId(ctx, { authUserId, userId }); - await seedUserIfNeeded(ctx, { userId }); - - return userId; - }, -}); - -const linkUser = defineMutation({ - args: updateExistingUserArgs, - handler: async (ctx, args) => { - // - const { userId, authUserId, email, name, image } = args; - - const user = await findUser(ctx, { userId }); - if (!user) throw NotFound(); + console.info('new user!', userId); await setAuthUserAppUserId(ctx, { authUserId, userId }); - await ctx.db.patch(userId, { - authUserId, - email, - name, - image, - }); + await ensureUserRootDirectory(ctx, { owner: userId }); return userId; }, }); -const patchUser = defineMutation({ +const syncAuthUser = defineMutation({ args: updateExistingUserArgs, handler: async (ctx, args) => { // - const { userId, authUserId, email, name, image } = args; + const { userId, authUserId, email, name, image, isAnonymous } = args; const user = await findUser(ctx, { userId }); if (!user) throw NotFound(); @@ -355,7 +202,9 @@ const patchUser = defineMutation({ email, name, image, + isAnonymous: Boolean(isAnonymous), }); + await ensureUserRootDirectory(ctx, { owner: userId }); return userId; }, diff --git a/apps/meseeks/convex/users.ts b/apps/meseeks/convex/users.ts index d39a9a11..f3af4f64 100644 --- a/apps/meseeks/convex/users.ts +++ b/apps/meseeks/convex/users.ts @@ -1,6 +1,5 @@ -import { internalMutation, query } from 'lib/convex'; -import { findActiveTasks } from './tasks.private'; -import { addUser, getCurrentUser, isProSubscriber, updateUser } from './users.private'; +import { internalMutation, mutation, query } from 'lib/convex'; +import { addUser, ensureUserRoot, getCurrentUser, updateUser } from './users.private'; // called by the better auth user.onCreate trigger to add the app user row or // link the auth user to an existing one. @@ -21,29 +20,12 @@ export const current = query({ handler: getCurrentUser, }); -export const currentIfPro = query({ +export const ensureRoot = mutation({ args: {}, handler: async (ctx) => { // const currentUser = await getCurrentUser(ctx, {}); - const isCurrentUserProSubscriber = await isProSubscriber(ctx, { - owner: currentUser._id, - }); - - if (!isCurrentUserProSubscriber) throw new Error('User is not Pro.'); - - return currentUser; - }, -}); - -export const findLockedBalance = query({ - args: {}, - handler: async (ctx) => { - // - const currentUser = await getCurrentUser(ctx, {}); - const activeTasks = await findActiveTasks(ctx, { owner: currentUser._id }); - - return activeTasks.reduce((acc, task) => acc + task.energyBudget.available, 0n); + return await ensureUserRoot(ctx, { userId: currentUser._id }); }, }); diff --git a/apps/meseeks/convex/users/preferences.private.ts b/apps/meseeks/convex/users/preferences.private.ts deleted file mode 100644 index 1ef39b9c..00000000 --- a/apps/meseeks/convex/users/preferences.private.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { zid } from 'convex-helpers/server/zod3'; -import { z } from 'zod/v3'; -import { defineMutation, defineQuery } from 'lib/convex'; - -export const findUserPreference = defineQuery({ - args: z.object({ - userId: zid('users'), - key: z.string(), - }), - handler: async (ctx, { userId, key }) => { - // - return await ctx.db - .query('user_preferences') - .withIndex('by_owner_key', (q) => q.eq('owner', userId).eq('key', key)) - .unique(); - }, -}); - -export const setUserPreference = defineMutation({ - args: z.object({ - userId: zid('users'), - key: z.string(), - value: z.unknown(), - }), - handler: async (ctx, { userId, key, value }) => { - // - const preference = await findUserPreference(ctx, { userId, key }); - - if (!preference) { - await ctx.db.insert('user_preferences', { owner: userId, key, value }); - } else { - await ctx.db.patch(preference._id, { value }); - } - }, -}); - -export const clearUserPreference = defineMutation({ - args: z.object({ - userId: zid('users'), - key: z.string(), - }), - handler: async (ctx, { userId, key }) => { - // - const preference = await findUserPreference(ctx, { userId, key }); - if (!preference) return; - - await ctx.db.delete(preference._id); - }, -}); diff --git a/apps/meseeks/convex/users/preferences.ts b/apps/meseeks/convex/users/preferences.ts deleted file mode 100644 index 92c9505e..00000000 --- a/apps/meseeks/convex/users/preferences.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { z } from 'zod/v3'; -import { internalMutation, internalQuery, mutation, query } from 'lib/convex'; -import { findUserPreference, setUserPreference } from './preferences.private'; -import { getCurrentUser } from '../users.private'; - -// used by magicRock.private.ts to inject stored userInfo into instruction templates -export const _getUserPreference = internalQuery({ - args: findUserPreference.args.shape, - handler: findUserPreference, -}); - -// used by skills/builtIn/setUserInfo.ts so the ai can persist learned user profile data -export const _setUserPreference = internalMutation({ - args: setUserPreference.args.shape, - handler: setUserPreference, -}); - -export const get = query({ - args: { - key: z.string(), - }, - handler: async (ctx, { key }) => { - // - const user = await getCurrentUser(ctx, {}); - return await findUserPreference(ctx, { userId: user._id, key }); - }, -}); - -export const set = mutation({ - args: { - key: z.string(), - value: z.unknown(), - }, - handler: async (ctx, { key, value }) => { - // - const user = await getCurrentUser(ctx, {}); - return await setUserPreference(ctx, { userId: user._id, key, value }); - }, -}); diff --git a/apps/meseeks/convex/users/themes.ts b/apps/meseeks/convex/users/themes.ts deleted file mode 100644 index cc10d155..00000000 --- a/apps/meseeks/convex/users/themes.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { mutation, query } from 'lib/convex'; -import { appThemeIdSchema, type AppThemeId } from '../../src/lib/themes/catalog'; -import { parseStoredThemeId } from '../../src/lib/themes/resolve'; -import { getCurrentUser } from '../users.private'; -import { clearUserPreference, findUserPreference, setUserPreference } from './preferences.private'; - -const themePreferenceKey = 'themeId'; - -const themeIconNameById = { - 'default-dark': 'moon-star', - 'default-light': 'sun-medium', - 'pastel-de-nata': 'cake-slice', - 'graphite-ledger': 'book-open-text', - 'ink-well': 'pen-tool', - 'moss-study': 'leaf', - 'oxblood-club': 'wine', - 'fjord-dusk': 'mountain', - 'limestone': 'landmark', - 'paper-and-ink': 'newspaper', - 'sage-receipt': 'receipt', - 'terracotta-desk': 'pencil-ruler', - 'harbor-mist': 'cloud', -} as const satisfies Record; - -export const get = query({ - args: {}, - handler: async (ctx) => { - // - const user = await getCurrentUser(ctx, {}); - const themePreference = await findUserPreference(ctx, { - userId: user._id, - key: themePreferenceKey, - }); - - return { - themeId: parseStoredThemeId(themePreference?.value), - themeIconNameById, - }; - }, -}); - -export const set = mutation({ - args: { - themeId: appThemeIdSchema, - }, - handler: async (ctx, { themeId }) => { - // - const user = await getCurrentUser(ctx, {}); - await setUserPreference(ctx, { - userId: user._id, - key: themePreferenceKey, - value: themeId, - }); - - return { themeId }; - }, -}); - -export const reset = mutation({ - args: {}, - handler: async (ctx) => { - // - const user = await getCurrentUser(ctx, {}); - - await clearUserPreference(ctx, { - userId: user._id, - key: themePreferenceKey, - }); - - return { themeId: null }; - }, -}); diff --git a/apps/meseeks/lib/cron.ts b/apps/meseeks/lib/cron.ts deleted file mode 100644 index 3456a590..00000000 --- a/apps/meseeks/lib/cron.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { CronExpressionParser } from 'cron-parser'; - -/** - * Calculate the next run time for a cron expression - */ -export function computeNextRun( - expression: string, // - timeZone: string, - startDate?: Date, -) { - const interval = CronExpressionParser.parse(expression, { - tz: timeZone, - startDate, - }); - - return interval.next().toDate(); -} - -export function getNextDates( - expression: string, // - timeZone: string, - count: number, - startDate?: Date, -) { - // - const interval = CronExpressionParser.parse(expression, { - tz: timeZone, - startDate, - }); - - const dates = []; - for (let i = 0; i < count; i++) { - dates.push(interval.next().toDate()); - } - - return dates; -} - -/** - * Validate if a cron expression is valid - */ -export function isExpressionValid(expression: string): boolean { - // - try { - CronExpressionParser.parse(expression); - return true; - } catch { - return false; - } -} diff --git a/apps/meseeks/lib/instinct.ts b/apps/meseeks/lib/instinct.ts new file mode 100644 index 00000000..6c7f6a05 --- /dev/null +++ b/apps/meseeks/lib/instinct.ts @@ -0,0 +1,61 @@ +import { z } from 'zod/v3'; +import type { Doc, Id } from 'convex/_generated/dataModel'; +import type { PerformResult, StagedText } from 'schemas/reactorSchema'; +import { instinctSchema } from 'schemas/skillSchema'; + +type BaseContext = { + action: Doc<'actions'>; + preparation?: Record; + warnings: Array; +}; + +type InstinctContext> = BaseContext & { + input: Input; +}; + +type InstinctReactor = { + stageText(args: { owner: Id<'users'>; content: string; contentType: string }): Promise; +}; + +type InstinctDefinition = { + key: z.infer['kind']; + description: string; + inputSchema: InputSchema; + outputSchema: OutputSchema; + perform( + context: InstinctContext>, + reactor: InstinctReactor, + ): Promise | PerformResult; +}; + +type RuntimeInstinctDefinition = Omit< + InstinctDefinition, + 'perform' +> & { + perform( + context: InstinctContext>, + reactor: InstinctReactor, + ): Promise | PerformResult; +}; + +export function defineInstinct( + definition: InstinctDefinition, +): RuntimeInstinctDefinition { + // + return { + key: definition.key, + description: definition.description, + inputSchema: definition.inputSchema, + outputSchema: definition.outputSchema, + perform(context, reactor) { + // + return definition.perform( + { + ...context, + input: definition.inputSchema.parse(context.input), + }, + reactor, + ); + }, + }; +} diff --git a/apps/meseeks/lib/polar.ts b/apps/meseeks/lib/polar.ts index b07a8bc2..296fea95 100644 --- a/apps/meseeks/lib/polar.ts +++ b/apps/meseeks/lib/polar.ts @@ -28,7 +28,13 @@ export const handlePolarWebhook = httpAction(async (ctx, request) => { // TODO: Make webhook persistence and side effects idempotent before relying on Polar retries. // persist all events - await ctx.runMutation(internal.topUps._persistPolarEvent, { polarEvent: json }); + await ctx.runMutation(internal.polarEvents._recordPolarEvent, { + event: { + type: json.type, + eventId: json.data?.id, + data: json.data, + }, + }); // will throw if invalid schema const parsed = webhookEventSchema.safeParse(json); @@ -47,18 +53,12 @@ export const handlePolarWebhook = httpAction(async (ctx, request) => { const paidPayload = orderPaidSchema.parse(json); const billingReason = paidPayload.data.billing_reason; - const isRenewal = billingReason === 'subscription_cycle'; - const isNewSubscription = billingReason === 'subscription_create'; console.debug('Processing order.paid', { productId: paidPayload.data.product_id, billingReason, - isRenewal, - isNewSubscription, - subscriptionId: paidPayload.data.subscription_id, }); - // TODO: Use product-specific schemas so subscription products require subscription_id and top-ups do not. switch (paidPayload.data.product_id) { // case env.POLAR_TOP_UP_ID: @@ -69,34 +69,6 @@ export const handlePolarWebhook = httpAction(async (ctx, request) => { }); break; - case env.POLAR_SUBSCRIPTION_ID: - await activateSubscription({ - ctx, - // TODO: For renewal events, look up the existing subscription by subscription_id instead of checkout_id. - checkoutId: paidPayload.data.checkout_id, - amount: paidPayload.data.net_amount / 100, // cents to dollars - // TODO: Use Polar current_period_end instead of local 30-day month math. - durationMonths: 1, - isFounder: false, - isRenewal, - polarSubscriptionId: paidPayload.data.subscription_id, - }); - break; - - case env.POLAR_FOUNDER_PACK_ID: - await activateSubscription({ - ctx, - // TODO: For renewal events, look up the existing subscription by subscription_id instead of checkout_id. - checkoutId: paidPayload.data.checkout_id, - amount: paidPayload.data.net_amount / 100, // cents to dollars - // TODO: Use Polar current_period_end instead of local 30-day month math. - durationMonths: 24, - isFounder: true, - isRenewal, - polarSubscriptionId: paidPayload.data.subscription_id, - }); - break; - default: console.debug('Unknown product payment', paidPayload.data.product_id); } @@ -106,18 +78,6 @@ export const handlePolarWebhook = httpAction(async (ctx, request) => { // TODO: implement automatic refund handling console.error('Not implemented: order.refunded', event); break; - - // case 'subscription.canceled': - // just let it expire naturally - // this will work but subscriptions will appear "active" but won't be treated as valid because of `validUntil` - // TODO: schedule a mutation to set 'canceled' - - case 'subscription.revoked': - await handleImmediateRevocation({ - ctx, - polarSubscriptionId: event.data.id, - }); - break; } return new Response(null, { status: 200 }); @@ -154,50 +114,6 @@ async function finishTopUp({ }); } -async function activateSubscription(params: { - ctx: ActionCtx; - checkoutId: string; - amount: number; - durationMonths: number; - isFounder: boolean; - isRenewal: boolean; - polarSubscriptionId?: string; -}) { - const { ctx, checkoutId, amount, durationMonths, isFounder, isRenewal, polarSubscriptionId } = params; - - console.debug('Activating subscription', { - checkoutId, - amount, - durationMonths, - isFounder, - isRenewal, - polarSubscriptionId, - }); - - return await ctx.runMutation(internal.subscriptions._activate, { - checkoutId, - months: durationMonths, - credits: asBigInt({ dollars: amount }), - isFounder, - isRenewal, - polarSubscriptionId, - }); -} - -async function handleImmediateRevocation({ - ctx, // - polarSubscriptionId, -}: { - ctx: ActionCtx; - polarSubscriptionId: string; -}) { - console.debug('Processing subscription revocation', { polarSubscriptionId }); - - return await ctx.runMutation(internal.subscriptions._handleRevocation, { - polarSubscriptionId, - }); -} - class PayloadParseError extends Error { // constructor(message: string) { diff --git a/apps/meseeks/package.json b/apps/meseeks/package.json index ba19400b..5cb772d0 100644 --- a/apps/meseeks/package.json +++ b/apps/meseeks/package.json @@ -18,33 +18,27 @@ "start": "bun .output/server/index.mjs", "typecheck": "tsgo --noEmit --incremental --tsBuildInfoFile .output/typecheck.tsbuildinfo", "test": "bun test", - "lint": "oxfmt --check src convex lib schemas skills scripts tailwind.config.ts tailwind-safelist.ts vite.config.ts postcss.config.cjs components.json convex.json package.json tsconfig.json && oxlint src convex lib schemas skills scripts tailwind.config.ts tailwind-safelist.ts vite.config.ts", - "format": "oxfmt src convex lib schemas skills scripts tailwind.config.ts tailwind-safelist.ts vite.config.ts postcss.config.cjs components.json package.json tsconfig.json", + "lint": "oxfmt --check src convex lib schemas scripts tailwind.config.ts tailwind-safelist.ts vite.config.ts postcss.config.cjs components.json convex.json package.json tsconfig.json && oxlint src convex lib schemas scripts tailwind.config.ts tailwind-safelist.ts vite.config.ts", + "format": "oxfmt src convex lib schemas scripts tailwind.config.ts tailwind-safelist.ts vite.config.ts postcss.config.cjs components.json package.json tsconfig.json", "sync-env-to-preview": "bun ./scripts/sync-env-to-preview.ts" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { - "@ai-sdk/anthropic": "^3.0.33", - "@ai-sdk/cerebras": "^2.0.27", - "@ai-sdk/deepinfra": "^2.0.26", "@ai-sdk/deepseek": "^2.0.15", - "@ai-sdk/google": "^3.0.18", - "@ai-sdk/groq": "^3.0.19", "@ai-sdk/openai": "^3.0.23", "@ai-sdk/openai-compatible": "^2.0.24", - "@ai-sdk/provider-utils": "^4.0.19", - "@ai-sdk/xai": "^3.0.44", + "@aws-sdk/client-s3": "3.1065.0", + "@aws-sdk/s3-request-presigner": "3.1065.0", "@babel/core": "^7.28.0", "@babel/preset-react": "^7.27.1", "@convex-dev/better-auth": "^0.10.13", - "@convex-dev/migrations": "^0.3.4", + "@convex-dev/migrations": "0.3.4", "@convex-dev/react-query": "^0.0.0-alpha.8", "@hookform/resolvers": "4.1.3", "@mdx-js/mdx": "^3.1.1", "@mdx-js/rollup": "^3.1.0", - "@openrouter/ai-sdk-provider": "^2.1.1", "@polar-sh/sdk": "^0.32.12", "@reactor/ui": "workspace:*", "@sentry/react": "^9.29.0", @@ -55,14 +49,12 @@ "@tanstack/react-router": "1.141.6", "@tanstack/react-router-ssr-query": "1.141.6", "@tanstack/react-start": "1.141.7", - "@vercel/analytics": "^1.4.1", "@vercel/speed-insights": "^1.1.0", "@vitejs/plugin-react": "^5.1.2", "ai": "^6.0.64", "better-auth": "^1.4.9", "convex": "1.38.0", "convex-helpers": "^0.1.112", - "cron-parser": "^5.3.0", "dset": "^3.1.4", "lucide-react": "^0.514.0", "react": "^19.2.3", diff --git a/apps/meseeks/schemas/actionDetailSchema.tsx b/apps/meseeks/schemas/actionDetailSchema.tsx index d4d55676..6c6ed086 100644 --- a/apps/meseeks/schemas/actionDetailSchema.tsx +++ b/apps/meseeks/schemas/actionDetailSchema.tsx @@ -1,162 +1,155 @@ import { zid } from 'convex-helpers/server/zod3'; import { z } from 'zod/v3'; -import { skillKindSchema } from './skillSchema'; - -const httpMethodSchema = z.enum(['GET', 'POST', 'PUT', 'DELETE', 'PATCH']); -const httpStatusCodeSchema = z.number().min(100).max(599); -const bodySizeSchema = z.number().min(0); - -const toolCallSchema = z.object({ - toolName: z.string(), - input: z.record(z.unknown()), +import { newActionSchema } from './actionSchema'; +import { intelligenceKeys } from './intelligenceSchema'; +import { skillKeySchema } from './skillSchema'; + +const coreActionDetailSchema = z.object({ + owner: zid('users'), + action: zid('actions'), + createdAt: z.number(), }); -const temperatureSchema = z.number().min(0).max(2).describe('Temperature setting for model randomness (0-2)'); - -const tokenUsageSchema = z - .object({ - input: z - .object({ - total: z.number().min(0).describe('Total input tokens used'), - cached: z.number().min(0).optional().describe('Number of cached input tokens'), - }) - .describe('Input token usage breakdown'), - output: z - .object({ - total: z.number().min(0).describe('Total output tokens generated'), - cached: z.number().min(0).optional().describe('Number of cached output tokens'), - }) - .describe('Output token usage breakdown'), - }) - .describe('Comprehensive token usage statistics'); - -const baseActionDetailSchema = z - .object({ - actionId: zid('actions').describe('Reference to the action this detail belongs to'), - skillKind: skillKindSchema.exclude(['built-in']).describe('Type of skill that was executed'), - skillKey: z.string().describe('Unique identifier of the skill'), - skillDescription: z.string().describe('Human-readable description of what the skill does'), - }) - .describe('Base information for any action execution'); - -// For hard actions (HTTP calls) -const httpActionDetailSchema = baseActionDetailSchema - .extend({ - skillKind: z.literal('hard'), - http: z - .object({ - // request details (sanitized for security) - method: httpMethodSchema.optional().describe('HTTP method used'), - url: z.string().url().optional().describe('Full URL that was called (including query params)'), - requestBodySize: bodySizeSchema.optional().describe('Size of request body in bytes'), - - // note: request headers and body are not stored as they may contain sensitive information - - // response details - statusCode: httpStatusCodeSchema.optional().describe('HTTP response status code'), - statusText: z.string().optional().describe('HTTP response status message'), - responseBodySize: bodySizeSchema.optional().describe('Size of response body in bytes'), - responseBody: z - .string() - .optional() - .describe( - 'Full HTTP response body (truncated based on MAX_HTTP_RESPONSE_BODY_BYTES env setting for safety within Convex 1MB document limit)', - ), - responseHeaders: z - .record(z.string()) - .optional() - .describe('HTTP response headers (generally safe to store)'), - }) - .describe('HTTP request/response details with security-conscious data filtering'), - }) - .describe('Debugging information for HTTP-based skill executions'); +export const providerActionDetailSchema = coreActionDetailSchema.extend({ + kind: z.literal('provider'), + provider: z.string().min(1), + model: z.string().optional(), + request: z.record(z.unknown()).optional(), + response: z.record(z.unknown()).optional(), + usage: z.record(z.unknown()).optional(), + cost: z.bigint().optional(), +}); -// For soft actions (LLM calls) -const llmActionDetailSchema = baseActionDetailSchema - .extend({ - skillKind: z.literal('soft'), - llm: z - .object({ - // model configuration - model: z.string().describe('Specific model that was used for this execution'), - provider: z.string().describe('AI provider (extracted from model)'), - temperature: temperatureSchema.describe('Temperature setting used for this call'), - maxTokens: z.number().min(1).optional().describe('Maximum tokens limit set for generation'), +export const boxActionDetailSchema = coreActionDetailSchema.extend({ + kind: z.literal('box'), + provider: z.literal('daytona'), + providerBoxId: z.string().min(1), + command: z.string().optional(), + exitCode: z.number().int().optional(), + stdout: z.string().optional(), + stderr: z.string().optional(), + logs: z.string().optional(), + changedPaths: z.array(z.string()).optional(), +}); - // context information - systemInstructions: z.string().describe('System prompt that was provided to the model'), - historyLength: z.number().min(0).describe('Number of conversation messages in context'), - history: z - .array( - z - .object({ - role: z - .enum([ - 'system', // - 'user', - 'assistant', - 'tool', - 'data', - 'function', - ]) - .describe('Role of the message sender'), - content: z.string().describe('Content of the message'), - }) - .describe('Individual message in the conversation history'), - ) - .describe('Complete conversation history that was sent to the model'), - availableTools: z.array(z.string()).describe('List of tool keys that were made available to the model'), +export const triggerActionDetailSchema = coreActionDetailSchema.extend({ + kind: z.literal('trigger'), + trigger: zid('triggers').optional(), + sourceFile: zid('files').optional(), + sourcePath: z.string().optional(), + sourceHash: z.string().optional(), + sourceAction: zid('actions').optional(), + compiledBy: zid('actions').optional(), + compiledAt: z.number().optional(), + matchedRevisions: z.array(zid('file_revisions')).optional(), + matchedPaths: z.array(z.string()).optional(), + proposals: z.array(newActionSchema).optional(), + acceptedActions: z.array(zid('actions')).optional(), + error: z.string().optional(), +}); - // execution results - finishReason: z - .string() - .optional() - .describe('Why the model stopped generating (stop, length, tool-calls, etc.)'), +export const reactionActionDetailSchema = coreActionDetailSchema.extend({ + kind: z.literal('reaction'), + proposals: z.array(newActionSchema), + acceptedActions: z.array(zid('actions')), +}); - text: z.string().optional().describe('Direct text response from the model'), +export const fileActionDetailSchema = coreActionDetailSchema.extend({ + kind: z.literal('file'), + file: zid('files').optional(), + revisions: z.array(zid('file_revisions')).optional(), + paths: z.array(z.string()).optional(), + warnings: z.array(z.string()).optional(), +}); - toolCalls: z.array(toolCallSchema).optional().describe('List of tool calls made by the model'), +export const uploadActionDetailSchema = coreActionDetailSchema.extend({ + kind: z.literal('upload'), + ticketAction: zid('actions'), + parent: zid('files'), + name: z.string().min(1), + contentType: z.string().min(1), + size: z.number().int().min(0), + hash: z.string().min(1), + checksum: z.string().min(1), + stagedStorageKey: z.string().min(1), + uploadUrl: z.string().url(), + expiresAt: z.number(), +}); - // comprehensive usage statistics - usage: tokenUsageSchema.optional().describe('Detailed token usage breakdown for cost analysis'), +export const errorActionDetailSchema = coreActionDetailSchema.extend({ + kind: z.literal('error'), + code: z.string().optional(), + message: z.string().min(1), + stack: z.string().optional(), +}); - // warnings and metadata (preserved as-is for debugging) - warnings: z.array(z.unknown()).optional().describe('AI SDK warnings (can be complex objects)'), +const preparationCoreSchema = coreActionDetailSchema.extend({ + kind: z.literal('preparation'), + skill: skillKeySchema, + preparedAt: z.number(), + warnings: z.array(z.string()).optional(), +}); - providerMetadata: z.record(z.unknown()).optional().describe('Provider-specific metadata for debugging'), - }) - .describe('Comprehensive LLM execution details including model config, context, and results'), - }) - .describe('Debugging information for LLM-based skill executions'); +export const thinkPreparationActionDetailSchema = preparationCoreSchema.extend({ + skillKind: z.literal('think'), + intelligence: intelligenceKeys, + provider: z.string().min(1), + model: z.string().min(1), + system: z.string().min(1), + prompt: z.string().min(1), + estimated: z.record(z.unknown()).optional(), +}); -export const actionDetailSchema = z - .union([httpActionDetailSchema, llmActionDetailSchema]) - .describe('Complete action execution details for debugging and transparency'); +export const requestPreparationActionDetailSchema = preparationCoreSchema.extend({ + skillKind: z.literal('request'), + url: z.string().url(), + method: z.enum([ + 'GET', // + 'POST', + 'PUT', + 'PATCH', + 'DELETE', + ]), + headers: z.record(z.string()).optional(), + body: z.unknown().optional(), + timeoutMs: z.number().int().positive().optional(), +}); -const llmUpdateFields = z.object({ - finishReason: z.string().optional(), - text: z.string().optional(), - toolCalls: z.array(toolCallSchema).optional(), - usage: tokenUsageSchema.optional(), - warnings: z.array(z.unknown()).optional(), - providerMetadata: z.record(z.unknown()).optional(), +export const executePreparationActionDetailSchema = preparationCoreSchema.extend({ + skillKind: z.literal('execute'), + root: zid('files'), + code: z.string().min(1), + language: z.enum([ + 'javascript', // + 'python', + ]), + timeoutSeconds: z.number().int().positive().optional(), }); -const httpUpdateFields = z.object({ - requestBodySize: bodySizeSchema.optional(), - statusCode: httpStatusCodeSchema.optional(), - statusText: z.string().optional(), - responseBodySize: bodySizeSchema.optional(), - responseBody: z.string().optional(), - responseHeaders: z.record(z.string()).optional(), +export const instinctPreparationActionDetailSchema = preparationCoreSchema.extend({ + skillKind: z.literal('instinct'), + context: z.record(z.unknown()).optional(), }); -// Update schema - only mutable fields can be updated -export const actionDetailUpdateSchema = z.union([ - z.object({ - llm: llmUpdateFields, - }), - z.object({ - http: httpUpdateFields, - }), +export const preparationActionDetailSchema = z.discriminatedUnion('skillKind', [ + thinkPreparationActionDetailSchema, + requestPreparationActionDetailSchema, + executePreparationActionDetailSchema, + instinctPreparationActionDetailSchema, ]); + +export const actionDetailSchema = z + .union([ + thinkPreparationActionDetailSchema, + requestPreparationActionDetailSchema, + executePreparationActionDetailSchema, + instinctPreparationActionDetailSchema, + providerActionDetailSchema, + boxActionDetailSchema, + triggerActionDetailSchema, + reactionActionDetailSchema, + fileActionDetailSchema, + uploadActionDetailSchema, + errorActionDetailSchema, + ]) + .describe('Technical detail for action execution.'); diff --git a/apps/meseeks/schemas/actionSchema.tsx b/apps/meseeks/schemas/actionSchema.tsx index 983af7f1..61216a39 100644 --- a/apps/meseeks/schemas/actionSchema.tsx +++ b/apps/meseeks/schemas/actionSchema.tsx @@ -1,40 +1,8 @@ import { zid } from 'convex-helpers/server/zod3'; import { z } from 'zod/v3'; import { authorSchema } from './authorSchema'; -import { tokenSchema } from './topUpSchema'; - -export const newActionSchema = z.object({ - taskId: zid('tasks'), - owner: zid('users'), - author: authorSchema, - skillKey: z.string().describe('The key of the skill to use'), - args: z.record(z.any()), - depth: z.number().min(0).max(1000), - status: z.enum(['enqueued', 'succeeded']).default('enqueued').optional(), - result: z.string().optional(), -}); - -const coreActionSchema = z.object({ - taskId: zid('tasks'), - owner: zid('users'), - author: authorSchema, - depth: z.number().min(0).max(1000), - skillKey: z.string(), - args: z.record(z.any()), - // TODO: idea: inherit the argsSchema from the skill, so we can drill types - estimatedCost: z.bigint().optional(), - startedAt: z.number().optional(), - scheduledFunctionId: zid('_scheduled_functions') - .optional() - .describe('Internal Convex scheduler id for the action execution function.'), - approvedAt: z.number().optional(), - approvedBy: z - .union([ - zid('users'), // - z.literal('auto'), - ]) - .optional(), -}); +import { intelligenceKeys } from './intelligenceSchema'; +import { skillKeySchema } from './skillSchema'; export const pendingActionStatusSchema = z.enum([ 'pending authorization', // @@ -50,25 +18,47 @@ export const resolvedActionStatusSchema = z.enum([ export const actionStatusSchema = pendingActionStatusSchema.or(resolvedActionStatusSchema); +export const actionSkillSchema = skillKeySchema; +export const actionSparkSchema = zid('actions').or(z.literal('self')); + +export const actionCostSchema = z.object({ + kind: z.string().min(1), + amount: z.bigint(), + description: z.string().optional(), +}); + +export const newActionSchema = z + .object({ + skill: actionSkillSchema, + input: z.record(z.unknown()), + }) + .describe('Proposed follow-up action.'); + +const coreActionSchema = z.object({ + owner: zid('users'), + root: zid('files'), + index: z.number().int().min(1), + author: authorSchema, + spark: actionSparkSchema, + skill: actionSkillSchema, + intelligence: intelligenceKeys.optional(), + input: z.record(z.unknown()), + claimedAt: z.number().optional(), + scheduledFunctionId: zid('_scheduled_functions').optional(), + startedAt: z.number().optional(), + interruptedAt: z.number().optional(), + output: zid('files').optional(), + costs: z.array(actionCostSchema).optional(), + warnings: z.array(z.string()).optional(), +}); + export const pendingActionSchema = coreActionSchema.extend({ status: pendingActionStatusSchema, - result: z.null().optional().default(null), // <------ }); export const resolvedActionSchema = coreActionSchema.extend({ status: resolvedActionStatusSchema, - result: z.object({ - text: z.string().optional(), - // setAt: z.number(), - reactions: z.array(newActionSchema), - }), - costs: z.array( - z.object({ - symbol: tokenSchema, - amount: z.bigint(), - description: z.string(), - }), - ), + finishedAt: z.number(), }); export const actionSchema = z @@ -76,6 +66,4 @@ export const actionSchema = z pendingActionSchema, // resolvedActionSchema, ]) - .describe( - 'An Action is any occurrence within a Task.', // - ); + .describe('Durable ledger row for work.'); diff --git a/apps/meseeks/schemas/boxSchema.ts b/apps/meseeks/schemas/boxSchema.ts new file mode 100644 index 00000000..f1453f29 --- /dev/null +++ b/apps/meseeks/schemas/boxSchema.ts @@ -0,0 +1,24 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; + +export const boxStatusSchema = z.enum([ + 'creating', // + 'ready', + 'busy', + 'stopped', + 'failed', +]); + +export const boxSchema = z + .object({ + owner: zid('users'), + root: zid('files'), + provider: z.literal('daytona'), + providerBoxId: z.string().min(1), + status: boxStatusSchema, + lastAction: zid('actions').optional(), + lastStartedAt: z.number().optional(), + lastStoppedAt: z.number().optional(), + metadata: z.record(z.unknown()).optional(), + }) + .describe('Compute provider lifecycle metadata.'); diff --git a/apps/meseeks/schemas/componentSchema.tsx b/apps/meseeks/schemas/componentSchema.tsx deleted file mode 100644 index 48cf3d5e..00000000 --- a/apps/meseeks/schemas/componentSchema.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { zid } from 'convex-helpers/server/zod3'; -import { z } from 'zod/v3'; -// import { authorSchema } from './authorSchema'; - -export const componentSchema = z.object({ - owner: z.union([ - zid('users'), // - z.literal('isPro'), - ]), - // author: authorSchema, - body: z.string().max(100000).describe('MDX'), - defaultTaskId: zid('tasks').optional(), - isPublic: z.boolean().optional().default(false), - slug: z - .string() - .optional() - .describe( - 'The slug of the component, used to identify it in the URL. If undefined, the component cannot be accessed directly via URL.', - ), -}); diff --git a/apps/meseeks/schemas/draftSchema.ts b/apps/meseeks/schemas/draftSchema.ts deleted file mode 100644 index bff36c75..00000000 --- a/apps/meseeks/schemas/draftSchema.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { zid } from 'convex-helpers/server/zod3'; -import { z } from 'zod/v3'; - -export const draftQueueItemSchema = z.object({ - skillKey: z.string(), - args: z.record(z.unknown()), -}); - -export const draftSchema = z.object({ - owner: zid('users'), - taskId: zid('tasks'), - queue: z.array(draftQueueItemSchema), - message: z.string(), - updatedAt: z.number(), -}); diff --git a/apps/meseeks/schemas/envSchema.ts b/apps/meseeks/schemas/envSchema.ts index 3926d6c7..71a9e51e 100644 --- a/apps/meseeks/schemas/envSchema.ts +++ b/apps/meseeks/schemas/envSchema.ts @@ -6,68 +6,15 @@ export const env = createEnv({ runtimeEnv: process.env, server: { - // + // app SITE_URL: z.string().min(1).describe('The app public URL.'), - ENV_TYPE: z.enum(['production', 'preview', 'development']).describe('Deployment environment.'), - BETTER_AUTH_SECRET: z.string().min(1).describe('Better Auth application secret.'), - - POLAR_SERVER: z.enum(['sandbox', 'production']).default('sandbox').describe('Polar server.'), - POLAR_ACCESS_TOKEN: z.string().min(1).describe('Polar Access Token for payment processing.'), - POLAR_WEBHOOK_SECRET: z.string().min(1).describe('Polar webhook secret for payment verification.'), - POLAR_SUBSCRIPTION_ID: z.string().min(1).describe('Pro subscription product ID.'), - POLAR_FOUNDER_PACK_ID: z.string().min(1).describe('Founder pack product ID.'), - POLAR_TOP_UP_ID: z.string().min(1).describe('Top up product ID.'), - PAYMENT_ETH_ADDRESS_BASE_CHAIN: z.string().min(1).describe('The Base wallet address to receive payments.'), - - ACTION_COST_USD: z - .string() - .min(1) - .transform((s) => BigInt(s)) - .pipe(z.bigint()) - .describe('The cost of one action in USD.'), - - CHAR_PER_TOKEN: z - .string() - .min(1) - .transform((s) => Number.parseInt(s, 10)) - .pipe(z.number()) - .describe('The number of characters per token to account for in cost prediction.'), - - COST_PREDICTION_MARGIN: z - .string() - .min(1) - .transform((s) => Number.parseInt(s, 10)) - .pipe(z.number()) - .describe('The cost prediction margin, in percentage (e.g. "50" for 50%).'), - - MAX_HTTP_RESPONSE_BODY_BYTES: z - .string() - .transform((s) => Number.parseInt(s, 10)) - .pipe(z.number()) - .describe('Maximum HTTP response body size to store in action details (in bytes).') - .default('819200'), // 800KiB in bytes + ENV_TYPE: z.enum(['production', 'preview', 'development']), + NODE_ENV: z.enum(['development', 'production']).default('development'), + // auth + BETTER_AUTH_SECRET: z.string().min(1).describe('Better Auth application secret.'), AUTH_GOOGLE_ID: z.string().min(1).describe('Google OAuth client ID.'), AUTH_GOOGLE_SECRET: z.string().min(1).describe('Google OAuth client secret.'), - - ALLOWED_DOMAINS: z - .string() - .min(1) - // transform to array - .transform((s) => s.split(',')) - // make sure transform worked - .pipe(z.array(z.string())) - .describe('Comma-separated list of allowed domains to sign in with.'), - - ALLOWED_EMAILS: z - .string() - .min(1) - // transform to array - .transform((s) => s.split(',')) - // make sure transform worked - .pipe(z.array(z.string())) - .describe('Comma-separated list of allowed emails to sign in with.'), - JWT_SESSION_DURATION_MS: z .string() .min(1) @@ -86,27 +33,26 @@ export const env = createEnv({ .pipe(z.number()) .describe('Session update age in milliseconds (how often to refresh rolling sessions in background).'), - MAX_CONSECUTIVE_COMPANION_ACTIONS: z - .string() - .transform((s) => Number.parseInt(s, 10)) - .pipe(z.number()) - .describe('The maximum number of consecutive companion actions.') - .default('20'), - - MAX_CONTEXT_ACTIONS: z - .string() - .transform((s) => Number.parseInt(s, 10)) - .pipe(z.number()) - .describe('The maximum number of actions to load before token-based cropping.') - .default('40'), - - MAX_CONTEXT_TOKENS: z - .string() - .transform((s) => Number.parseInt(s, 10)) - .pipe(z.number().int().min(1)) - .describe('The maximum estimated tokens to keep in model context.') - .default('128000'), - + // object storage + OBJECT_STORAGE_BUCKET: z.string().min(1), + OBJECT_STORAGE_ENDPOINT: z.string().min(1), + OBJECT_STORAGE_REGION: z.string().min(1).default('auto'), + OBJECT_STORAGE_ACCESS_KEY_ID: z.string().min(1), + OBJECT_STORAGE_SECRET_ACCESS_KEY: z.string().min(1), + OBJECT_STORAGE_ROOT_PREFIX: z.string().min(1), + + // payments + POLAR_SERVER: z.enum(['sandbox', 'production']).default('sandbox'), + POLAR_ACCESS_TOKEN: z.string().min(1), + POLAR_WEBHOOK_SECRET: z.string().min(1), + POLAR_TOP_UP_ID: z.string().min(1), + + // boxes + DAYTONA_API_KEY: z.string().min(1), + DAYTONA_API_URL: z.string().min(1).optional(), + DAYTONA_TARGET: z.string().min(1).describe('Region/environment, e.g. `us` or `eu`.'), + + // reactor ACTION_TIMEOUT_BUFFER_MS: z .string() .transform((s) => Number.parseInt(s, 10)) @@ -114,19 +60,10 @@ export const env = createEnv({ .describe('How much earlier than Convex hard timeout actions should abort, in milliseconds.') .default('120000'), - ACTIVE_TASKS_RENDER_LIMIT: z - .string() - .transform((s) => Number.parseInt(s, 10)) - .pipe(z.number()) - .describe('Maximum number of active tasks to show in activeTasks variable.') - .default('20'), - - GROQ_API_KEY: z.string().min(1).describe('Groq API key.'), - INCEPTION_API_KEY: z.string().min(1).describe('Inception Labs API key.'), - MISTRAL_API_KEY: z.string().min(1).describe('Mistral API key.'), - MOONSHOT_API_KEY: z.string().min(1).describe('Moonshot API key.'), - - NODE_ENV: z.enum(['development', 'production']).default('development').describe('Automatically populated.'), + // intelligence providers + DEEPSEEK_API_KEY: z.string().min(1), + MOONSHOT_API_KEY: z.string().min(1), + OPENAI_API_KEY: z.string().min(1), }, /** diff --git a/apps/meseeks/schemas/fileRevisionSchema.ts b/apps/meseeks/schemas/fileRevisionSchema.ts new file mode 100644 index 00000000..cd5617e0 --- /dev/null +++ b/apps/meseeks/schemas/fileRevisionSchema.ts @@ -0,0 +1,24 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; + +export const fileRevisionChangeKindSchema = z.enum(['create', 'update', 'delete', 'rename', 'metadata', 'tag']); + +export const fileRevisionSchema = z + .object({ + owner: zid('users'), + file: zid('files'), + action: zid('actions'), + previousRevision: zid('file_revisions').optional(), + changeKind: fileRevisionChangeKindSchema, + beforePath: z.string().optional(), + afterPath: z.string().optional(), + beforeHash: z.string().optional(), + afterHash: z.string().optional(), + beforeSize: z.number().int().min(0).optional(), + afterSize: z.number().int().min(0).optional(), + storageKey: z.string().optional(), + contentType: z.string().optional(), + patch: z.string().optional().describe('Full reversible patch for this file mutation.'), + patchStorageKey: z.string().optional(), + }) + .describe('Immutable record of one file mutation.'); diff --git a/apps/meseeks/schemas/fileSchema.ts b/apps/meseeks/schemas/fileSchema.ts new file mode 100644 index 00000000..3623eb4d --- /dev/null +++ b/apps/meseeks/schemas/fileSchema.ts @@ -0,0 +1,37 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import { authorSchema } from './authorSchema'; + +export const fileKindSchema = z.enum(['directory', 'file']); +export const fileParentSchema = z.union([z.literal('root'), zid('files')]); + +export const fileMetadataSchema = z.record(z.unknown()); + +export const fileBudgetSchema = z.object({ + energy: z.bigint(), +}); + +export const fileSchema = z + .object({ + owner: zid('users'), + parent: fileParentSchema, + name: z.string(), + kind: fileKindSchema, + currentRevision: zid('file_revisions').optional(), + contentType: z.string().optional(), + size: z.number().int().min(0).optional(), + hash: z.string().optional(), + metadata: fileMetadataSchema.optional(), + budget: fileBudgetSchema.optional(), + author: authorSchema, + }) + .describe('Stable identity and current state for a file or directory.'); + +export const fileTagSchema = z + .object({ + owner: zid('users'), + file: zid('files'), + key: z.string().min(1), + value: z.string().optional(), + }) + .describe('Current tag index for files.'); diff --git a/apps/meseeks/schemas/intelligenceSchema.ts b/apps/meseeks/schemas/intelligenceSchema.ts index ab463e25..d7823d09 100644 --- a/apps/meseeks/schemas/intelligenceSchema.ts +++ b/apps/meseeks/schemas/intelligenceSchema.ts @@ -9,6 +9,18 @@ const WORD_TO_TOKEN_RATIO = 1 / TOKEN_TO_WORD_RATIO; // 1 word ≈ 1.333 tokens const INPUT_COST_WEIGHT = 80n; // input weights for ≈80% of the cost const OUTPUT_COST_WEIGHT = 20n; // output weights for ≈20% of the cost +export const intelligenceKeys = z.enum([ + 'deepseek/deepseek-v4-flash', + 'deepseek/deepseek-v4-pro', + 'moonshot/kimi-k2.5', + 'moonshot/kimi-k2.6', + 'openai/gpt-5.5', + 'openai/gpt-5.5-pro', + 'openai/gpt-5.4-mini', +]); + +export type IntelligenceKey = z.infer; + const buildContext = (maxTokens: number) => ({ maxTokens, maxWords: Math.round(maxTokens * TOKEN_TO_WORD_RATIO), @@ -43,358 +55,21 @@ const buildPricing = ({ input, output }: { input: number; output: number }) => { export const DEFAULT_INTELLIGENCE: IntelligenceKey = 'deepseek/deepseek-v4-flash'; // dynamically chooses the intelligence to use based on the available energy -export const INTELLIGENCE_PROGRESSION = { +export const INTELLIGENCE_PROGRESSION: Partial> = { 'deepseek/deepseek-v4-flash': 0.2, - 'moonshot/kimi-2.5': 50.0, - 'anthropic/claude-4.5-opus': Number.POSITIVE_INFINITY, -} as const; - -export const intelligenceKeys = z.enum([ - // - // Anthropic - 'anthropic/claude-4.5-opus', - 'anthropic/claude-4.1-opus', - 'anthropic/claude-4.5-sonnet', - 'anthropic/claude-4.5-haiku', - 'anthropic/claude-4-opus', - 'anthropic/claude-4-sonnet', - 'anthropic/claude-3.7-sonnet', - 'anthropic/claude-3.5-haiku', - - // OpenAI - // 'openai/gpt-5.1', - // 'openai/gpt-5.1-chat', - // 'openai/gpt-5.1-codex', - // 'openai/gpt-5.1-codex-mini', - 'openai/gpt-5.5', - 'openai/gpt-5.4', - 'openai/gpt-5', - 'openai/gpt-5-mini', - 'openai/gpt-5-nano', - 'openai/gpt-4.1', - 'openai/gpt-4.1-mini', - 'openai/gpt-4.1-nano', - 'openai/gpt-oss-120b', - 'openai/gpt-oss-20b', - - // Google - 'google/gemini-2.5-pro', - 'google/gemini-2.5-flash', - 'google/gemini-2.5-flash-lite', - - // xAI - 'xai/grok-4.1-fast-non-reasoning', - 'xai/grok-4', - 'xai/grok-4-fast-non-reasoning', - 'xai/grok-build-0.1', - 'xai/grok-code-fast-1', - 'xai/grok-3', - 'xai/grok-3-mini', - - // Groq - 'groq/qwen3-32b', - - // DeepSeek - 'deepseek/deepseek-v4-pro', - 'deepseek/deepseek-v4-flash', - 'deepseek/deepseek-v3', - - // Moonshot - 'moonshot/kimi-2', - 'moonshot/kimi-2.5', - - // Inception Labs - 'inception/mercury-2', - - // Cerebras - 'cerebras/qwen3-235b', - 'cerebras/zai-glm-4.7', - 'cerebras/zai-glm-4.6', - - // DeepInfra - 'deepinfra/qwen-3-coder', - 'deepinfra/glm-4.5', - - // OpenRouter - 'openrouter/qwen-3-coder', - 'openrouter/GLM-4.5-Air', - 'openrouter/GLM-4.5', -]); + 'moonshot/kimi-k2.5': 50.0, + 'openai/gpt-5.5-pro': Number.POSITIVE_INFINITY, +}; export const INTELLIGENCES: Record = { - // - - // ============================== - // Anthropic - // ============================== - 'anthropic/claude-4.5-opus': { - key: 'anthropic/claude-4.5-opus', - name: 'Claude 4.5 Opus', - description: 'GOAT performance 🐐, WOAT pricing ⚠️', - provider: 'Anthropic', - pricing: buildPricing({ input: 5, output: 25 }), - context: buildContext(200_000), - intelligenceLevel: 10, - }, - 'anthropic/claude-4.1-opus': { - key: 'anthropic/claude-4.1-opus', - name: 'Claude 4.1 Opus', - provider: 'Anthropic', - pricing: buildPricing({ input: 15, output: 75 }), - context: buildContext(128_000), - intelligenceLevel: 9, - }, - 'anthropic/claude-4.5-sonnet': { - key: 'anthropic/claude-4.5-sonnet', - name: 'Claude 4.5 Sonnet', - description: 'Best overall — way more costly than Grok.', - provider: 'Anthropic', - pricing: buildPricing({ input: 3, output: 15 }), - context: buildContext(128_000), - intelligenceLevel: 7, - }, - 'anthropic/claude-4.5-haiku': { - key: 'anthropic/claude-4.5-haiku', - name: 'Claude 4.5 Haiku', - description: 'Cheap', - provider: 'Anthropic', - pricing: buildPricing({ input: 1, output: 5 }), - context: buildContext(128_000), - intelligenceLevel: 5, - }, - 'anthropic/claude-4-opus': { - key: 'anthropic/claude-4-opus', - name: 'Claude 4 Opus', - provider: 'Anthropic', - pricing: buildPricing({ input: 15, output: 75 }), - context: buildContext(200_000), - intelligenceLevel: 10, - }, - 'anthropic/claude-4-sonnet': { - key: 'anthropic/claude-4-sonnet', - name: 'Claude 4 Sonnet', - provider: 'Anthropic', - pricing: buildPricing({ input: 3, output: 15 }), - context: buildContext(256_000), - intelligenceLevel: 8, - }, - 'anthropic/claude-3.7-sonnet': { - key: 'anthropic/claude-3.7-sonnet', - name: 'Claude 3.7 Sonnet', - provider: 'Anthropic', - // 3.7 kept for retro-compatibility - pricing: buildPricing({ input: 3, output: 15 }), - context: buildContext(256_000), - intelligenceLevel: 8, - }, - 'anthropic/claude-3.5-haiku': { - key: 'anthropic/claude-3.5-haiku', - name: 'Claude 3.5 Haiku', - description: 'Surprisingly very good, very cheap', - provider: 'Anthropic', - pricing: buildPricing({ input: 0.8, output: 4 }), - context: buildContext(200_000), - intelligenceLevel: 6, - }, - - // ============================== - // OpenAI - // ============================== - 'openai/gpt-5.5': { - key: 'openai/gpt-5.5', - name: 'GPT-5.5', - description: 'Not recommended. Use GPT-5.4 instead for half the price.', - provider: 'OpenAI', - pricing: buildPricing({ input: 5, output: 30 }), - context: buildContext(250_000), - intelligenceLevel: 8, - }, - 'openai/gpt-5.4': { - key: 'openai/gpt-5.4', - name: 'GPT-5.4', - description: '~le state of the art', - provider: 'OpenAI', - pricing: buildPricing({ input: 2.5, output: 15 }), - context: buildContext(250_000), - intelligenceLevel: 7, - }, - 'openai/gpt-5': { - key: 'openai/gpt-5', - name: 'GPT-5', - description: 'Testing', - provider: 'OpenAI', - pricing: buildPricing({ input: 1.25, output: 10 }), - context: buildContext(128_000), - intelligenceLevel: 4, - }, - 'openai/gpt-5-mini': { - key: 'openai/gpt-5-mini', - name: 'GPT-5 Mini', - provider: 'OpenAI', - pricing: buildPricing({ input: 0.25, output: 2 }), - context: buildContext(128_000), - intelligenceLevel: 7, - }, - 'openai/gpt-5-nano': { - key: 'openai/gpt-5-nano', - name: 'GPT-5 Nano', - provider: 'OpenAI', - pricing: buildPricing({ input: 0.05, output: 0.4 }), - context: buildContext(128_000), - intelligenceLevel: 5, - }, - 'openai/gpt-4.1': { - key: 'openai/gpt-4.1', - name: 'GPT-4.1', - provider: 'OpenAI', - pricing: buildPricing({ input: 2.0, output: 8.0 }), - context: buildContext(128_000), - intelligenceLevel: 8, - }, - 'openai/gpt-4.1-mini': { - key: 'openai/gpt-4.1-mini', - name: 'GPT-4.1 Mini', - provider: 'OpenAI', - pricing: buildPricing({ input: 0.4, output: 1.6 }), - context: buildContext(128_000), - intelligenceLevel: 6, - }, - 'openai/gpt-4.1-nano': { - key: 'openai/gpt-4.1-nano', - name: 'GPT-4.1 Nano', - provider: 'OpenAI', - pricing: buildPricing({ input: 0.1, output: 0.4 }), - context: buildContext(128_000), - intelligenceLevel: 4, - }, - 'openai/gpt-oss-120b': { - key: 'openai/gpt-oss-120b', - name: 'GPT OSS 120B', - provider: 'OpenAI', - pricing: buildPricing({ input: 0.25, output: 0.75 }), - context: buildContext(128_000), - intelligenceLevel: 7, - }, - 'openai/gpt-oss-20b': { - key: 'openai/gpt-oss-20b', - name: 'GPT OSS 20B', - provider: 'OpenAI', - pricing: buildPricing({ input: 0.1, output: 0.5 }), - context: buildContext(128_000), - intelligenceLevel: 5, - }, - - // ============================== - // Google - // ============================== - 'google/gemini-2.5-pro': { - key: 'google/gemini-2.5-pro', - name: 'Gemini 2.5 Pro', - provider: 'Google', - pricing: buildPricing({ input: 1.25, output: 10 }), - context: buildContext(1_000_000), - intelligenceLevel: 8, - }, - 'google/gemini-2.5-flash': { - key: 'google/gemini-2.5-flash', - name: 'Gemini 2.5 Flash', - description: 'Nicely balanced, cheap and fast', - provider: 'Google', - pricing: buildPricing({ input: 0.3, output: 2.5 }), + 'deepseek/deepseek-v4-flash': { + key: 'deepseek/deepseek-v4-flash', + name: 'DeepSeek V4 Flash', + provider: 'DeepSeek', + pricing: buildPricing({ input: 0.14, output: 0.28 }), context: buildContext(1_000_000), intelligenceLevel: 6, }, - 'google/gemini-2.5-flash-lite': { - key: 'google/gemini-2.5-flash-lite', - name: 'Gemini 2.5 Flash Lite', - provider: 'Google', - pricing: buildPricing({ input: 0.1, output: 0.4 }), - context: buildContext(1_000_000), - intelligenceLevel: 5, - }, - - // ============================== - // xAI - // ============================== - 'xai/grok-4.1-fast-non-reasoning': { - key: 'xai/grok-4.1-fast-non-reasoning', - name: 'Grok 4.1 Fast', - description: 'Great cost/performance ratio 📊', - provider: 'xAI', - pricing: buildPricing({ input: 0.2, output: 0.5 }), - context: buildContext(128_000), // TODO: can do up to 2M, but price doubles above 128K - intelligenceLevel: 5, - }, - 'xai/grok-4': { - key: 'xai/grok-4', - name: 'Grok 4', - description: 'Great cost/performance ratio — for daily use.', - provider: 'xAI', - pricing: buildPricing({ input: 3, output: 15 }), - context: buildContext(256_000), - intelligenceLevel: 5, - }, - 'xai/grok-4-fast-non-reasoning': { - key: 'xai/grok-4-fast-non-reasoning', - name: 'Grok 4 Fast', - description: 'Great cost/performance ratio — for daily use.', - provider: 'xAI', - pricing: buildPricing({ input: 0.2, output: 0.5 }), - context: buildContext(2_000_000), - intelligenceLevel: 5, - }, - 'xai/grok-build-0.1': { - key: 'xai/grok-build-0.1', - name: 'Grok Build 0.1', - description: 'Early-access coding model trained for agentic coding.', - provider: 'xAI', - pricing: buildPricing({ input: 1, output: 2 }), - context: buildContext(256_000), - intelligenceLevel: 5, - }, - 'xai/grok-code-fast-1': { - key: 'xai/grok-code-fast-1', - name: 'Grok Code Fast 1', - description: 'Great cost/performance ratio — for daily use.', - provider: 'xAI', - pricing: buildPricing({ input: 0.2, output: 1.5 }), - context: buildContext(131_000), - intelligenceLevel: 5, - }, - 'xai/grok-3': { - key: 'xai/grok-3', - name: 'Grok 3', - provider: 'xAI', - pricing: buildPricing({ input: 3, output: 15 }), - context: buildContext(131_000), - intelligenceLevel: 8, - }, - 'xai/grok-3-mini': { - key: 'xai/grok-3-mini', - name: 'Grok 3 Mini', - description: 'Cheap and fast, can be useful', - provider: 'xAI', - pricing: buildPricing({ input: 0.3, output: 0.5 }), - context: buildContext(131_000), - intelligenceLevel: 5, - }, - - // ============================== - // Groq - // ============================== - 'groq/qwen3-32b': { - key: 'groq/qwen3-32b', - name: 'Qwen 32B', - description: 'Insanely faaaast, but not very smart', - provider: 'Groq', - pricing: buildPricing({ input: 0.29, output: 0.59 }), - context: buildContext(32_000), - intelligenceLevel: 4, - }, - - // ============================== - // DeepSeek - // ============================== 'deepseek/deepseek-v4-pro': { key: 'deepseek/deepseek-v4-pro', name: 'DeepSeek V4 Pro', @@ -403,139 +78,53 @@ export const INTELLIGENCES: Record = { context: buildContext(1_000_000), intelligenceLevel: 8, }, - 'deepseek/deepseek-v4-flash': { - key: 'deepseek/deepseek-v4-flash', - name: 'DeepSeek V4 Flash', - provider: 'DeepSeek', - pricing: buildPricing({ input: 0.14, output: 0.28 }), - context: buildContext(1_000_000), - intelligenceLevel: 6, - }, - 'deepseek/deepseek-v3': { - key: 'deepseek/deepseek-v3', - name: 'DeepSeek V3', - provider: 'DeepSeek', - pricing: buildPricing({ input: 0.56, output: 1.68 }), - context: buildContext(128_000), - intelligenceLevel: 7, - }, - - // ============================== - // Moonshot - // ============================== - 'moonshot/kimi-2.5': { - key: 'moonshot/kimi-2.5', - name: 'Kimi 2.5', - description: 'Pareto frontier 🎯', + 'moonshot/kimi-k2.5': { + key: 'moonshot/kimi-k2.5', + name: 'Kimi K2.5', provider: 'Moonshot', pricing: buildPricing({ input: 0.6, output: 3 }), context: buildContext(250_000), intelligenceLevel: 9, }, - 'moonshot/kimi-2': { - key: 'moonshot/kimi-2', - name: 'Kimi 2', + 'moonshot/kimi-k2.6': { + key: 'moonshot/kimi-k2.6', + name: 'Kimi K2.6', provider: 'Moonshot', - pricing: buildPricing({ input: 0.6, output: 2.5 }), - context: buildContext(128_000), - intelligenceLevel: 7, - }, - - // ============================== - // Inception Labs - // ============================== - 'inception/mercury-2': { - key: 'inception/mercury-2', - name: 'Mercury 2', - provider: 'Inception Labs', - pricing: buildPricing({ input: 0.25, output: 0.75 }), - context: buildContext(128_000), - intelligenceLevel: 5, + pricing: buildPricing({ input: 0.6, output: 3 }), + context: buildContext(250_000), + intelligenceLevel: 9, }, - - // ============================== - // Cerebras - // ============================== - 'cerebras/qwen3-235b': { - key: 'cerebras/qwen3-235b', - name: 'Qwen 235B', - provider: 'Cerebras', - pricing: buildPricing({ input: 0, output: 0 }), - context: buildContext(128_000), + 'openai/gpt-5.5': { + key: 'openai/gpt-5.5', + name: 'GPT 5.5', + provider: 'OpenAI', + pricing: buildPricing({ input: 5, output: 30 }), + context: buildContext(250_000), intelligenceLevel: 8, }, - 'cerebras/zai-glm-4.7': { - key: 'cerebras/zai-glm-4.7', - name: 'GLM 4.7', - description: 'Faaaaast 🐆', - provider: 'Cerebras', - pricing: buildPricing({ input: 2.25, output: 2.75 }), - context: buildContext(128_000), - intelligenceLevel: 7, - }, - 'cerebras/zai-glm-4.6': { - key: 'cerebras/zai-glm-4.6', - name: 'GLM 4.6', - provider: 'Cerebras', - pricing: buildPricing({ input: 2.25, output: 2.75 }), - context: buildContext(128_000), - intelligenceLevel: 7, - }, - - // ============================== - // DeepInfra - // ============================== - 'deepinfra/qwen-3-coder': { - key: 'deepinfra/qwen-3-coder', - name: 'Qwen 3 Coder', - provider: 'DeepInfra', - pricing: buildPricing({ input: 0.4, output: 1.6 }), - context: buildContext(128_000), - intelligenceLevel: 7, - }, - 'deepinfra/glm-4.5': { - key: 'deepinfra/glm-4.5', - name: 'GLM 4.5', - provider: 'DeepInfra', - pricing: buildPricing({ input: 0.6, output: 2.2 }), - context: buildContext(128_000), - intelligenceLevel: 6, + 'openai/gpt-5.5-pro': { + key: 'openai/gpt-5.5-pro', + name: 'GPT 5.5 Pro', + provider: 'OpenAI', + pricing: buildPricing({ input: 15, output: 75 }), + context: buildContext(250_000), + intelligenceLevel: 10, }, - - // ============================== - // OpenRouter - // ============================== - 'openrouter/qwen-3-coder': { - key: 'openrouter/qwen-3-coder', - name: 'Qwen 3 Coder', - provider: 'OpenRouter', - pricing: buildPricing({ input: 0.6, output: 2.5 }), + 'openai/gpt-5.4-mini': { + key: 'openai/gpt-5.4-mini', + name: 'GPT 5.4 mini', + provider: 'OpenAI', + pricing: buildPricing({ input: 0.25, output: 2 }), context: buildContext(128_000), intelligenceLevel: 7, }, - 'openrouter/GLM-4.5-Air': { - key: 'openrouter/GLM-4.5-Air', - name: 'GLM 4.5 Air', - provider: 'OpenRouter', - pricing: buildPricing({ input: 0.2, output: 1.1 }), - context: buildContext(128_000), - intelligenceLevel: 5, - }, - 'openrouter/GLM-4.5': { - key: 'openrouter/GLM-4.5', - name: 'GLM 4.5', - provider: 'OpenRouter', - pricing: buildPricing({ input: 0.6, output: 2.2 }), - context: buildContext(128_000), - intelligenceLevel: 6, - }, }; export const intelligenceSchema = z.object({ key: intelligenceKeys, name: z.string(), description: z.string().optional(), - provider: z.string(), // TODO: enforce that this is a valid provider + provider: z.enum(['DeepSeek', 'Moonshot', 'OpenAI']), pricing: z.object({ inputPerToken: z.bigint(), inputPerMillionToken: z.bigint(), @@ -553,5 +142,4 @@ export const intelligenceSchema = z.object({ // TODO: see models.dev for other details to add }); -export type IntelligenceKey = z.infer; export type Intelligence = z.infer; diff --git a/apps/meseeks/schemas/pageSchema.ts b/apps/meseeks/schemas/pageSchema.ts new file mode 100644 index 00000000..03a12878 --- /dev/null +++ b/apps/meseeks/schemas/pageSchema.ts @@ -0,0 +1,22 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import { skillOwnerSchema } from './skillSchema'; + +export const pageSchema = z + .object({ + owner: skillOwnerSchema, + root: zid('files'), + file: zid('files'), + route: z.string().min(1), + sourcePath: z.string().min(1), + sourceHash: z.string().optional(), + compiledBy: zid('actions'), + compiledAt: z.number(), + status: z.enum([ + 'enabled', // + 'disabled', + 'errored', + ]), + diagnostics: z.array(z.string()).optional(), + }) + .describe('Compiled page projection.'); diff --git a/apps/meseeks/schemas/polarEventSchema.ts b/apps/meseeks/schemas/polarEventSchema.ts index db1c0397..e2b4186e 100644 --- a/apps/meseeks/schemas/polarEventSchema.ts +++ b/apps/meseeks/schemas/polarEventSchema.ts @@ -1,9 +1,25 @@ +import { zid } from 'convex-helpers/server/zod3'; import { z } from 'zod/v3'; +export const polarEventReceiptSchema = z + .object({ + owner: zid('users').optional(), + action: zid('actions').optional(), + type: z.string().min(1), + eventId: z.string().optional(), + data: z.record(z.unknown()).describe('Polar payload after secret stripping.'), + receivedAt: z.number(), + }) + .describe('Polar webhook receipt row.'); + export const polarEventSchema = z.object({ type: z.string(), timestamp: z.string().optional(), - data: z.record(z.any()), + data: z + .object({ + id: z.string().optional(), + }) + .catchall(z.unknown()), }); export const orderDataSchema = z.object({ @@ -19,37 +35,13 @@ export const orderDataSchema = z.object({ billing_reason: z .enum([ 'purchase', // - 'subscription_create', - 'subscription_cycle', - 'subscription_update', ]) .optional(), product_id: z.string(), checkout_id: z.string(), - subscription_id: z.string().optional(), - customer: z.object({ - external_id: z.string(), - }), -}); - -export const subscriptionDataSchema = z.object({ - id: z.string(), - status: z.enum([ - 'incomplete', // - 'active', - 'past_due', - 'canceled', - 'unpaid', - 'incomplete_expired', - 'trialing', - ]), customer: z.object({ external_id: z.string(), }), - product_id: z.string(), - cancel_at_period_end: z.boolean(), - current_period_end: z.string(), - ended_at: z.string().optional().nullable(), }); export const orderPaidSchema = z.object({ @@ -62,13 +54,4 @@ export const orderRefundedSchema = z.object({ data: orderDataSchema, }); -export const subscriptionRevokedSchema = z.object({ - type: z.literal('subscription.revoked'), - data: subscriptionDataSchema, -}); - -export const webhookEventSchema = z.discriminatedUnion('type', [ - orderPaidSchema, - orderRefundedSchema, - subscriptionRevokedSchema, -]); +export const webhookEventSchema = z.discriminatedUnion('type', [orderPaidSchema, orderRefundedSchema]); diff --git a/apps/meseeks/schemas/reactorSchema.ts b/apps/meseeks/schemas/reactorSchema.ts new file mode 100644 index 00000000..c57310a9 --- /dev/null +++ b/apps/meseeks/schemas/reactorSchema.ts @@ -0,0 +1,204 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import type { Doc } from 'convex/_generated/dataModel'; +import { preparationActionDetailSchema } from './actionDetailSchema'; +import { newActionSchema, resolvedActionStatusSchema } from './actionSchema'; +import { fileRevisionChangeKindSchema } from './fileRevisionSchema'; +import { + decisionConfigSchema, + executeConfigSchema, + httpConfigSchema, + skillKeySchema, + skillKindSchema, + skillSourceSchema, +} from './skillSchema'; + +export const performArgsSchema = z.object({ + action: zid('actions'), +}); + +export const claimedSkillSchema = z.object({ + source: skillSourceSchema, + key: skillKeySchema, + kind: skillKindSchema, + config: z.record(z.unknown()).optional(), + inputSchema: z.string(), + outputSchema: z.string(), +}); + +export const stagedTextSchema = z.object({ + content: z.string(), + contentType: z.string().min(1), + hash: z.string().min(1), + size: z.number().int().min(0), + storageKey: z.string().min(1), +}); + +export const storedBodySchema = z.object({ + contentType: z.string().min(1), + hash: z.string().min(1), + size: z.number().int().min(0), + storageKey: z.string().min(1), +}); + +export const fileMutationSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('createDirectory'), + parent: zid('files'), + name: z.string().min(1), + }), + z.object({ + kind: z.literal('createFile'), + parent: zid('files'), + name: z.string().min(1), + body: storedBodySchema, + }), + z.object({ + kind: z.literal('createText'), + parent: zid('files'), + name: z.string().min(1), + body: stagedTextSchema, + }), + z.object({ + kind: z.literal('createTextAtPath'), + parent: zid('files'), + path: z.array(z.string().min(1)).min(1), + body: stagedTextSchema, + }), + z.object({ + kind: z.literal('writeText'), + file: zid('files'), + beforeContent: z.string(), + expectedRevision: zid('file_revisions').optional(), + body: stagedTextSchema, + }), + z.object({ + kind: z.literal('move'), + file: zid('files'), + parent: zid('files').optional(), + name: z.string().min(1).optional(), + }), + z.object({ + kind: z.literal('tag'), + file: zid('files'), + key: z.string().min(1), + value: z.string().optional(), + }), + z.object({ + kind: z.literal('untag'), + file: zid('files'), + key: z.string().min(1), + }), +]); + +export const providerReceiptSchema = z.object({ + provider: z.string().min(1), + model: z.string().optional(), + request: z.record(z.unknown()).optional(), + response: z.record(z.unknown()).optional(), + usage: z.record(z.unknown()).optional(), +}); + +export const uploadTicketSchema = z.object({ + ticketAction: zid('actions'), + parent: zid('files'), + name: z.string().min(1), + contentType: z.string().min(1), + size: z.number().int().min(0), + hash: z.string().min(1), + checksum: z.string().min(1), + stagedStorageKey: z.string().min(1), + uploadUrl: z.string().url(), + expiresAt: z.number(), +}); + +export const triggerMutationSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('disableTrigger'), + trigger: zid('triggers'), + }), +]); + +const compiledSkillProjectionBaseSchema = z.object({ + key: skillKeySchema, + description: z.string().min(1), + inputSchema: z.string().min(1), + outputSchema: z.string().min(1), + sourceFile: zid('files'), + sourcePath: z.string().min(1), + sourceHash: z.string().optional(), +}); + +export const compiledSkillProjectionSchema = z.discriminatedUnion('kind', [ + compiledSkillProjectionBaseSchema.extend({ + kind: z.literal('think'), + config: decisionConfigSchema, + }), + compiledSkillProjectionBaseSchema.extend({ + kind: z.literal('request'), + config: httpConfigSchema, + }), + compiledSkillProjectionBaseSchema.extend({ + kind: z.literal('execute'), + config: executeConfigSchema, + }), +]); + +export const compiledPageProjectionSchema = z.object({ + file: zid('files'), + route: z.string().min(1), + sourcePath: z.string().min(1), + sourceHash: z.string().optional(), + diagnostics: z.array(z.string()).optional(), +}); + +export const compiledTriggerProjectionSchema = z.object({ + kind: z.literal('mutation'), + events: z.array(fileRevisionChangeKindSchema).min(1), + pattern: z.string().optional(), + reactions: z.array(newActionSchema).min(1).max(5), + maxUses: z.number().int().positive().optional(), + sourceFile: zid('files'), + sourcePath: z.string().min(1), + sourceHash: z.string().optional(), +}); + +export const compileMutationSchema = z.object({ + kind: z.literal('compile'), + skills: z.array(compiledSkillProjectionSchema), + pages: z.array(compiledPageProjectionSchema), + triggers: z.array(compiledTriggerProjectionSchema), + diagnostics: z.array(z.string()), +}); + +export const performResultSchema = z.object({ + action: zid('actions'), + status: resolvedActionStatusSchema, + output: stagedTextSchema.optional(), + fileMutations: z.array(fileMutationSchema).optional(), + triggerMutations: z.array(triggerMutationSchema).optional(), + compileMutations: z.array(compileMutationSchema).optional(), + reactions: z.array(newActionSchema).optional(), + providerReceipt: providerReceiptSchema.optional(), + uploadTicket: uploadTicketSchema.optional(), + error: z.string().optional(), + warnings: z.array(z.string()).optional(), +}); + +export type ClaimedSkill = z.infer; +export type StagedText = z.infer; +export type StoredBody = z.infer; +export type FileMutation = z.infer; +export type TriggerMutation = z.infer; +export type CompileMutation = z.infer; +export type ProviderReceipt = z.infer; +export type UploadTicket = z.infer; +export type PerformResult = z.infer; + +export type ClaimedAction = { + action: Doc<'actions'>; + input: Record; + preparation: z.infer; + skill: ClaimedSkill; + warnings: Array; +}; diff --git a/apps/meseeks/schemas/scheduleSchema.ts b/apps/meseeks/schemas/scheduleSchema.ts deleted file mode 100644 index 06fb9738..00000000 --- a/apps/meseeks/schemas/scheduleSchema.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { zid } from 'convex-helpers/server/zod3'; -import { z } from 'zod/v3'; -import { authorSchema } from './authorSchema'; - -export const timeZoneSchema = z - .string() - .describe( - 'Timezone using the IANA Time Zone Database format. e.g. `America/New_York`, `Europe/Paris`, `Asia/Tokyo`.', - ); - -const coreScheduleSchema = z.object({ - taskId: zid('tasks'), - owner: zid('users'), - author: authorSchema, - skillKey: z.string().describe('The skill to execute when scheduled'), - args: z.record(z.any()).describe('Arguments to pass to the skill'), - timeZone: timeZoneSchema, - lastRunAt: z.number().optional().describe('Timestamp of last execution'), - nextRunAt: z.number().describe('Timestamp of next scheduled execution'), - scheduledJobId: z.string().optional().describe('Convex scheduler job ID for cancellation'), -}); - -export const oneTimeScheduleSchema = coreScheduleSchema.extend({ - scheduleType: z.literal('one-time'), - scheduledAt: z.number().describe('Timestamp when to execute once'), -}); - -export const recurringScheduleSchema = coreScheduleSchema.extend({ - scheduleType: z.literal('recurring'), - cronExpression: z.string().describe('Cron expression for recurring execution'), -}); - -export const scheduleSchema = z.union([ - oneTimeScheduleSchema, // - recurringScheduleSchema, -]); - -// Separate schemas for new schedule creation (without computed fields) -const newCoreScheduleSchema = z.object({ - taskId: zid('tasks'), - owner: zid('users'), - author: authorSchema, - skillKey: z.string().describe('The skill to execute when scheduled'), - args: z.record(z.any()).describe('Arguments to pass to the skill'), - timeZone: timeZoneSchema, -}); - -export const newOneTimeScheduleSchema = newCoreScheduleSchema.extend({ - scheduleType: z.literal('one-time'), - scheduledAt: z.number().describe('Timestamp when to execute once'), -}); - -export const newRecurringScheduleSchema = newCoreScheduleSchema.extend({ - scheduleType: z.literal('recurring'), - cronExpression: z.string().describe('Cron expression for recurring execution'), -}); - -export const newScheduleSchema = z.union([ - newOneTimeScheduleSchema, // - newRecurringScheduleSchema, -]); diff --git a/apps/meseeks/schemas/skillSchema.tsx b/apps/meseeks/schemas/skillSchema.tsx index 25e21657..c16f8561 100644 --- a/apps/meseeks/schemas/skillSchema.tsx +++ b/apps/meseeks/schemas/skillSchema.tsx @@ -4,24 +4,63 @@ import { asBigInt } from 'lib/money'; import { authorSchema } from './authorSchema'; import { intelligenceKeys } from './intelligenceSchema'; +export const skillKeySchema = z + .string() + .min(1) + .regex(/^(?:@[A-Za-z0-9][A-Za-z0-9_-]*\/)?[A-Za-z][A-Za-z0-9:_/-]*$/); + export const skillOwnerSchema = z.union([ - z.literal('built-in'), // built-in to Meseeks - z.literal('isPro'), // managed by us, offered by third-parties - zid('users'), // managed by users + z.literal('isPro'), // + zid('users'), ]); export const skillAuthorSchema = z.union([ - authorSchema, // user or meseeks-defined skills - z.literal('built-in'), // global skills + authorSchema, // + z.literal('isPro'), +]); + +export const configuredSkillKindSchema = z.enum([ + 'think', // + 'request', + 'execute', ]); -export const skillKindSchema = z.enum([ - 'built-in', // - 'hard', - 'soft', +export const instinctSkillKindSchema = z.enum([ + 'say', // + 'think', + 'request', + 'execute', + 'create', + 'write', + 'move', + 'tag', + 'untag', + 'interrupt', + 'seed', + 'prepareUpload', + 'commitUpload', + 'createTrigger', + 'disableTrigger', + 'compile', ]); -// TODO: idea: the initial seed is just an action that happens on the onboarding task +export const skillKindSchema = configuredSkillKindSchema.or(instinctSkillKindSchema); + +export const skillSourceSchema = z.enum([ + 'instinct', // code-owned runtime skill + 'file', + 'manual', +]); + +export const serializedSchemaStringSchema = z.string().min(1); + +export const instinctSchema = z.object({ + key: skillKeySchema, + kind: instinctSkillKindSchema, + description: z.string(), + inputSchema: serializedSchemaStringSchema, + outputSchema: serializedSchemaStringSchema, +}); export const httpConfigSchema = z.object({ url: z.string().url(), @@ -29,10 +68,10 @@ export const httpConfigSchema = z.object({ 'GET', // 'POST', 'PUT', - 'DELETE', 'PATCH', + 'DELETE', ]), - headers: z.record(z.string()).describe('HTTP headers to send with the request'), + headers: z.record(z.string()).optional(), paramMappings: z.array( z.object({ type: z.enum([ @@ -42,268 +81,175 @@ export const httpConfigSchema = z.object({ 'body', 'bodyPath', ]), - source: z.string().describe('the parameter name on inputSchema'), - target: z - .string() - .describe('the parameter name on the URL, header, path, or body, depending on the selected type'), + source: z.string(), + target: z.string(), }), ), body: z .object({ - template: z.record(z.any()).describe('Base JSON object with pre-filled values').optional(), + template: z.record(z.unknown()).optional(), }) .optional(), }); export const instructionVariableSchema = z.union([ - z.literal('task').describe('The full task structure, in a XML-like format'), - z.literal('task.id'), - z.literal('task.title'), - z.literal('task.status'), - z.literal('task.createdAt'), - z.literal('task.lastUpdatedAt'), - z.literal('task.instructions'), - z.literal('task.summary'), - z.literal('task.parent'), - z.literal('task.energyBudget').describe('The full task budget structure, in a XML-like format'), - z.literal('task.energyBudget.total'), - z.literal('task.energyBudget.spent'), - z.literal('task.energyBudget.available'), - z.literal('taskSchedules').describe('List of active schedules for the current task, in a XML-like format'), - z.literal('currentDate').describe('The current date and time in ISO 8601 format'), - z.literal('userInfo').describe('Information about the user, written by themself'), - z.literal('allSkills').describe('A list of all existing skills.'), - z.literal('activeSkills').describe('A list of skills enabled by the user through preferences.'), - z.literal('activeTasks').describe('A list of all active tasks ordered by total budget (highest first).'), - z - .string() - .regex(/^input\..+$/) - .describe('Access to action input arguments using input.argName format'), + z.literal('root'), + z.literal('root.id'), + z.literal('root.path'), + z.literal('root.metadata'), + z.literal('root.budget'), + z.literal('action'), + z.literal('action.id'), + z.literal('action.skill'), + z.literal('action.input'), + z.literal('files'), + z.literal('currentDate'), + z.literal('userInfo'), + z.literal('allSkills'), + z.literal('activeSkills'), + z.string().regex(/^input\..+$/), ]); export const decisionConfigSchema = z.object({ model: intelligenceKeys.or(z.literal('auto')), - instructions: z.string().describe('Instructions for the decision-making process'), - temperature: z.number().min(0).max(2).describe('Temperature to use'), - availableSkills: z.array(z.string()).describe('Skills that can be used to make the decision'), + instructions: z.string(), + temperature: z.number().min(0).max(2), + availableSkills: z.array(skillKeySchema), historyMode: z.enum([ // 'none', // 'since last instructed', 'all', ]), - topP: z - .number() - .min(0) - .max(1) - .optional() - .describe( - 'Nucleus sampling. This is a number between 0 and 1. E.g. 0.1 would mean that only tokens with the top 10% probability mass are considered. It is recommended to set either `temperature` or `topP`, but not both.', - ), - topK: z - .number() - .optional() - .describe( - 'Only sample from the top K options for each subsequent token. Only sample from the top K options for each subsequent token. Used to remove "long tail" low probability responses. Recommended for advanced use cases only. Usually `temperature` is enough.', - ), - maxTokens: z - .number() // - .optional() - .describe('Maximum number of tokens to use'), - maxRetries: z - .number() - .optional() - .describe('Maximum number of (AI SDK internal) retries. Set to 0 to disable retries.'), - frequencyPenalty: z - .number() - .min(-1) - .max(1) - .optional() - .describe( - `Affects the likelihood of the model to repeatedly use the same words or phrases. Is a number between -1 (increase repetition) and 1 (maximum penalty, decrease repetition). 0 means no penalty.`, - ), - stopSequences: z - .array(z.string()) - .optional() - .describe( - 'If set, the model will stop generating text when one of the stop sequences is generated. Providers may have limits on the number of stop sequences.', - ), - seed: z - .number() - .optional() - .describe( - 'The seed (integer) to use for random sampling. If set and supported by the model, calls will generate deterministic results.', - ), + topP: z.number().min(0).max(1).optional(), + topK: z.number().optional(), + maxTokens: z.number().optional(), + maxRetries: z.number().optional(), + frequencyPenalty: z.number().min(-1).max(1).optional(), + stopSequences: z.array(z.string()).optional(), + seed: z.number().optional(), }); -// Export individual schemas for reuse in forms and other contexts -export const preApprovedCostSchema = z.union([ - z.literal('none'), +export const executeConfigSchema = z.object({ + language: z + .enum([ + 'javascript', // + 'python', + ]) + .optional(), + timeoutSeconds: z.number().int().positive().optional(), +}); + +export const skillCostSchema = z.union([ + z.literal('dynamic'), // z .bigint() .min(asBigInt({ dollars: 0 })) - .max(asBigInt({ dollars: 1000 })) - .describe( - 'If the expected cost is less than or equal to this amount (pre-approved cost), it will be automatically authorized to execute. If can be set to "none" to disable pre-approval at all, forcing a human-approval before execution.', - ), + .max(asBigInt({ dollars: 1000 })), ]); -export const knownReactionsSchema = z - .array( - z.object({ - skillKey: z.string().describe('The key of the skill to use'), - args: z.record(z.any()), - condition: z.enum([ - 'owner', // only react if the author is the owner - 'companion', // only react if the author is a an action (i.e. companion did it) - 'any', // always react - ]), - }), - ) - .optional() - .describe('Pre-configured actions that will happen as a re-action to the use of this skill.'); +export const preApprovedCostSchema = z.union([ + z.literal('none'), // + z + .bigint() + .min(asBigInt({ dollars: 0 })) + .max(asBigInt({ dollars: 1000 })), +]); const coreSkillSchema = z.object({ - key: z.string().min(3).describe('The key of the skill. Must be unique.'), + key: skillKeySchema, description: z.string(), - inputSchema: z.string(), // TODO: enforce that this is a valid zod schema - // outputSchema?: z.string(), // not yet + inputSchema: serializedSchemaStringSchema, + outputSchema: serializedSchemaStringSchema, preApprovedCost: preApprovedCostSchema, - knownReactions: knownReactionsSchema, - kind: skillKindSchema, owner: skillOwnerSchema, author: skillAuthorSchema, - isHidden: z.boolean().optional().describe('Whether the skill is hidden from /skills.'), - priority: z - .number() - .optional() - .describe('The priority of the skill, visual only. Used to sort the skills list. Lower = higher.'), + source: skillSourceSchema, + root: zid('files').optional(), + sourceFile: zid('files').optional(), + sourcePath: z.string().optional(), + sourceHash: z.string().optional(), + compiledBy: zid('actions').optional(), + compiledAt: z.number().optional(), + isHidden: z.boolean().optional(), + priority: z.number().optional(), +}); + +export const instinctSkillSchema = coreSkillSchema.extend({ + source: z.literal('instinct'), + kind: instinctSkillKindSchema, + cost: skillCostSchema, + config: z.record(z.unknown()).optional(), +}); + +const configuredSkillSchema = coreSkillSchema.extend({ + source: z.union([ + z.literal('file'), // + z.literal('manual'), + ]), }); -export const builtInSkillSchema = coreSkillSchema.extend({ - kind: z.literal('built-in'), - owner: z.literal('built-in'), - author: z.literal('built-in'), - cost: z.literal(0n).describe('Built-in skills are free of charge.'), +export const thinkSkillSchema = configuredSkillSchema.extend({ + kind: z.literal('think'), + cost: z.literal('dynamic'), + config: decisionConfigSchema, }); -export const hardSkillSchema = coreSkillSchema.extend({ - kind: z.literal('hard'), +export const requestSkillSchema = configuredSkillSchema.extend({ + kind: z.literal('request'), cost: z - .bigint() // + .bigint() .min(asBigInt({ dollars: 0 })) - .max(asBigInt({ dollars: 1000 })) - .describe('The cost to use this skill, in energy.'), + .max(asBigInt({ dollars: 1000 })), config: httpConfigSchema, }); -export const softSkillSchema = coreSkillSchema.extend({ - kind: z.literal('soft'), - cost: z - .literal('dynamic') - .describe( - 'The cost to use this skill, in energy. Dynamic cost means it will be known during usage. Budget is still accounted before execution.', - ), - config: decisionConfigSchema, +export const executeSkillSchema = configuredSkillSchema.extend({ + kind: z.literal('execute'), + cost: z.literal('dynamic'), + config: executeConfigSchema, }); export const skillSchema = z .union([ - builtInSkillSchema, // - hardSkillSchema, - softSkillSchema, + instinctSkillSchema, // + thinkSkillSchema, // + requestSkillSchema, + executeSkillSchema, ]) - .describe( - 'A Skill is an external API call (service or LLM) that can be used by the user or Meseeks.', // - ); + .describe('Action definition stored in the skills table.'); export const newSkillSchema = z.union([ - hardSkillSchema.omit({ author: true, owner: true }), // - softSkillSchema.omit({ author: true, owner: true }), + thinkSkillSchema.omit({ + author: true, + owner: true, + source: true, + root: true, + sourceFile: true, + sourcePath: true, + sourceHash: true, + compiledBy: true, + compiledAt: true, + }), // + requestSkillSchema.omit({ + author: true, + owner: true, + source: true, + root: true, + sourceFile: true, + sourcePath: true, + sourceHash: true, + compiledBy: true, + compiledAt: true, + }), + executeSkillSchema.omit({ + author: true, + owner: true, + source: true, + root: true, + sourceFile: true, + sourcePath: true, + sourceHash: true, + compiledBy: true, + compiledAt: true, + }), ]); - -// Instincts/built-in skills -// speak() -// createTask() -// markAsDone() -// ... - -// // managed by us, offered by third-parties -// react() -// learn() -// searchWeb() -// scrapeTweet() -// ... - -// // managed by you -// ... - -// ------------------------------------ -// ------------------------------------ -// ------------------------------------ - -export const simplifiedSkillKindSchema = z.enum(['hard', 'soft']); - -export const simplifiedDecisionConfigSchema = z.object({ - instructions: z.string().describe('Instructions for the decision-making process'), - temperature: z.number().min(0).max(2).describe('Temperature to use'), - availableSkills: z.array(z.string()).describe('Skills that can be used to make the decision'), -}); - -const simplifiedCoreSkillSchema = z.object({ - key: z.string(), - description: z.string(), - inputSchema: z.string(), // TODO: enforce that this is a valid zod schema - isSafe: z.boolean(), - knownReactions: z - .array(z.string().describe('The key of the skill to use')) - .optional() - .describe('Pre-configured actions that will happen as a re-action to the use of this skill.'), - kind: simplifiedSkillKindSchema, -}); - -export const simplifiedHttpConfigSchema = z.object({ - url: z.string(), - method: z.enum([ - 'GET', // - 'POST', - 'PUT', - 'DELETE', - 'PATCH', - ]), - headers: z.record(z.string()).describe('HTTP headers to send with the request').optional(), - paramMappings: z.array( - z.object({ - type: z.enum([ - 'search', // - 'header', - 'path', - 'body', - 'bodyPath', - ]), - source: z.string().describe('the parameter name on inputSchema'), - target: z - .string() - .describe( - 'the parameter name on the URL, header, path, or body, depending on the selected type. For `path` params, they should appear at the URL as `https://example.com/path/:param1/:param2`.', - ), - }), - ), - body: z - .object({ - template: z.record(z.any()).describe('Base JSON object with pre-filled values').optional(), - }) - .optional(), -}); - -export const simplifiedHardSkillSchema = simplifiedCoreSkillSchema.extend({ - kind: z.literal('hard'), - config: simplifiedHttpConfigSchema, -}); - -export const simplifiedSoftSkillSchema = simplifiedCoreSkillSchema.extend({ - kind: z.literal('soft'), - config: simplifiedDecisionConfigSchema, -}); - -// Simplified skill schema for the learn() skill TODO: organize better -export const simplifiedSkillSchema = z.union([simplifiedHardSkillSchema, simplifiedSoftSkillSchema]); diff --git a/apps/meseeks/schemas/subscriptionSchema.tsx b/apps/meseeks/schemas/subscriptionSchema.tsx deleted file mode 100644 index 0f86cc09..00000000 --- a/apps/meseeks/schemas/subscriptionSchema.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { zid } from 'convex-helpers/server/zod3'; -import { z } from 'zod/v3'; - -export const subscriptionStatusSchema = z.enum([ - 'pending', // - 'active', - 'canceled', - 'past_due', - 'unpaid', - 'revoked', -]); - -export const subscriptionSchema = z.object({ - polarSubscriptionId: z.string().optional(), - owner: zid('users'), - status: subscriptionStatusSchema, - validUntil: z.number().optional(), - paymentUrl: z.string().url(), - paymentId: z.string(), - renewalCount: z.number().optional().default(0), - lastRenewalDate: z.number().optional(), - isFounder: z.boolean().optional().default(false), -}); diff --git a/apps/meseeks/schemas/taskSchema.tsx b/apps/meseeks/schemas/taskSchema.tsx deleted file mode 100644 index a83ab4a8..00000000 --- a/apps/meseeks/schemas/taskSchema.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { zid } from 'convex-helpers/server/zod3'; -import { z } from 'zod/v3'; -import { authorSchema } from './authorSchema'; -import { intelligenceKeys } from './intelligenceSchema'; - -export const taskStatusSchema = z.enum([ - 'idle', // - 'acting', // companion is working - 'unread', // companion is not working, you have unread actions - 'blocked', // companion is blocked, requires your attention - 'discarded', // task was discarded, not relevant for future reference - 'done', // task was resolved, actions will be used for learning -]); - -export const taskBudgetSchema = z.object({ - total: z - .bigint() // - .describe('The total amount of energy the user has budgeted for this task.'), - available: z - .bigint() // - .describe('The remaining/available amount of energy available to spend on this task (total - spent).'), -}); - -export const taskSchema = z - .object({ - author: authorSchema.describe('Who created the task.'), - owner: zid('users').describe('The user who is responsible for the task.'), - title: z.string().max(60).optional().describe('A short title for the task. Max 60 characters.'), - instructions: z - .string() - .optional() - .describe('An MDX detailed description of what should be done to achieve the task.'), - summary: z.string().optional().describe('A summary of the task activity.'), - status: taskStatusSchema, - isActive: z.boolean().describe('Computed from status.'), - parentId: zid('tasks').optional().describe('The parent task ID of this task.'), - lastUpdatedAt: z.number().optional().describe('The last time the task instructions were reviewed/updated.'), - // lastReadAction: zid('actions').optional().describe('The last action that was "read" by the user.'), - energyBudget: taskBudgetSchema, - embeddingId: zid('taskEmbeddings').optional(), - preferredIntelligence: intelligenceKeys.optional().describe('The preferred intelligence to use for this task.'), - availableSkills: z - .array(z.string()) - .max(16) - .optional() - .describe('List of skill keys that are available for this task. Max 16 skills.'), - }) - .describe(`It's a goal to be achieved. A Task is the basic and most fundamental entity of Meseeks.`); - -export const taskEmbeddingsSchema = z.object({ - taskId: zid('tasks'), - embedding: z.array(z.number()), - status: taskStatusSchema, -}); diff --git a/apps/meseeks/schemas/toolSchema.tsx b/apps/meseeks/schemas/toolSchema.tsx index 06552389..bf48d18f 100644 --- a/apps/meseeks/schemas/toolSchema.tsx +++ b/apps/meseeks/schemas/toolSchema.tsx @@ -3,7 +3,7 @@ import type { z } from 'zod/v3'; import type { newActionSchema } from './actionSchema'; import type { tokenSchema } from './topUpSchema'; -// standardized tool result type for all Meseeks tools +// standardized tool result type for all PRO tools. export type AIToolResult = { result: { text?: string | undefined; diff --git a/apps/meseeks/schemas/topUpSchema.tsx b/apps/meseeks/schemas/topUpSchema.tsx index d088f5e8..ae7e6fe3 100644 --- a/apps/meseeks/schemas/topUpSchema.tsx +++ b/apps/meseeks/schemas/topUpSchema.tsx @@ -1,18 +1,8 @@ import { zid } from 'convex-helpers/server/zod3'; import { z } from 'zod/v3'; -import { asBigInt } from 'lib/money'; import { authorSchema } from './authorSchema'; -export const blockchainSchema = z.enum([ - 'ethereum', // - 'base', - 'worldchain', - 'optimism', -]); - -export const tokenSchema = z.enum([ - 'USD', // -]); +export const tokenSchema = z.literal('energy'); export const topUpStatusSchema = z.enum([ 'waiting', // @@ -21,24 +11,18 @@ export const topUpStatusSchema = z.enum([ 'discarded by user', ]); -export const walletAddressSchema = z.string().describe('The address of the recipient.'); - -export const topUpAmountSchema = z - .bigint() - .min(asBigInt({ dollars: 10 }), 'Minimum amount is 10 USD') - .max(asBigInt({ dollars: 100000 }), 'That much? Are you sure?'); +export const topUpAmountSchema = z.bigint().min(1n).describe('Usable energy amount.'); export const topUpSchema = z .object({ - chain: blockchainSchema, - symbol: tokenSchema, - amount: topUpAmountSchema, - to: walletAddressSchema, - description: z.string(), - status: topUpStatusSchema, owner: zid('users'), author: authorSchema, - paymentUrl: z.string().url().describe('The URL the user will be redirected to pay.'), - paymentId: z.string().describe('The ID of the Polar checkout.'), + amount: topUpAmountSchema, + fee: z.bigint(), + totalCharged: z.bigint(), + status: topUpStatusSchema, + paymentUrl: z.string().url().optional(), + paymentId: z.string().optional(), + provider: z.literal('polar'), }) - .describe('A topUp to be executed on the blockchain.'); + .describe('One-time energy deposit checkout state.'); diff --git a/apps/meseeks/schemas/transactionSchema.tsx b/apps/meseeks/schemas/transactionSchema.tsx index e9ce5111..39c80a99 100644 --- a/apps/meseeks/schemas/transactionSchema.tsx +++ b/apps/meseeks/schemas/transactionSchema.tsx @@ -1,59 +1,16 @@ import { zid } from 'convex-helpers/server/zod3'; import { z } from 'zod/v3'; -import { tokenSchema } from './topUpSchema'; -export const valueSchema = z.object({ - symbol: tokenSchema, - amount: z.bigint(), -}); - -export const freeCreditsTransactionSchema = z.object({ - kind: z.literal('free credits'), - value: valueSchema, - owner: zid('users'), - description: z.string().optional(), -}); - -export const topUpTransactionSchema = z.object({ - kind: z.literal('top up'), - value: valueSchema, - topUpId: zid('topUps'), - owner: zid('users'), - description: z.string().optional(), -}); - -export const taskCostTransactionSchema = z.object({ - kind: z.literal('fund task'), - value: valueSchema, - taskId: zid('tasks'), - owner: zid('users'), - description: z.string().optional(), -}); - -export const refundTaskTransactionSchema = z.object({ - kind: z.literal('refund from task'), - value: valueSchema, - taskId: zid('tasks'), - owner: zid('users'), - description: z.string().optional(), -}); - -export const subscriptionTransactionSchema = z.object({ - kind: z.literal('subscription'), - value: valueSchema, - subscriptionId: zid('subscriptions'), - owner: zid('users'), - description: z.string().optional(), -}); +export const transactionKindSchema = z.enum(['free energy', 'top up', 'action cost', 'storage cost', 'refund']); export const transactionSchema = z - .union([ - freeCreditsTransactionSchema, // - topUpTransactionSchema, - taskCostTransactionSchema, - refundTaskTransactionSchema, - subscriptionTransactionSchema, - ]) - .describe( - 'A financial transaction. Top ups, pay outs and task/action execution costs.', // - ); + .object({ + owner: zid('users'), + kind: transactionKindSchema, + value: z.bigint(), + action: zid('actions').optional(), + file: zid('files').optional(), + topUp: zid('top_ups').optional(), + description: z.string().optional(), + }) + .describe('Energy ledger transaction.'); diff --git a/apps/meseeks/schemas/triggerSchema.ts b/apps/meseeks/schemas/triggerSchema.ts new file mode 100644 index 00000000..001f22ce --- /dev/null +++ b/apps/meseeks/schemas/triggerSchema.ts @@ -0,0 +1,52 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import { newActionSchema } from './actionSchema'; +import { fileRevisionChangeKindSchema } from './fileRevisionSchema'; + +export const triggerStatusSchema = z.enum(['enabled', 'disabled', 'errored']); + +const baseTriggerSchema = z.object({ + owner: zid('users'), + root: zid('files'), + author: zid('actions'), + status: triggerStatusSchema, + sourceFile: zid('files').optional(), + sourcePath: z.string().optional(), + sourceHash: z.string().optional(), + compiledBy: zid('actions').optional(), + compiledAt: z.number().optional(), + maxUses: z.number().int().positive().optional(), + remainingUses: z.number().int().min(0).optional(), + lastRunAt: z.number().optional(), + lastError: z.string().optional(), + runCount: z.number().int().min(0).default(0), +}); + +export const codeTriggerSchema = baseTriggerSchema.extend({ + kind: z.literal('code'), + file: zid('files'), +}); + +export const mutationTriggerSchema = baseTriggerSchema.extend({ + kind: z.literal('mutation'), + events: z.array(fileRevisionChangeKindSchema), + pattern: z.string().optional(), + reactions: z.array(newActionSchema).min(1).max(5), +}); + +export const actionTriggerSchema = baseTriggerSchema.extend({ + kind: z.literal('action'), + skills: z.array(z.string()).optional(), + statuses: z.array(z.string()).optional(), +}); + +export const scheduleTriggerSchema = baseTriggerSchema.extend({ + kind: z.literal('schedule'), + schedule: z.string().describe('One-time timestamp or recurring expression. Timestamp values include timezone.'), + nextRunAt: z.number().optional(), + scheduledFunctionId: zid('_scheduled_functions').optional(), +}); + +export const triggerSchema = z + .union([codeTriggerSchema, mutationTriggerSchema, actionTriggerSchema, scheduleTriggerSchema]) + .describe('Durable Reactor rule.'); diff --git a/apps/meseeks/schemas/userSchema.tsx b/apps/meseeks/schemas/userSchema.tsx index a4a37f72..b7317a7e 100644 --- a/apps/meseeks/schemas/userSchema.tsx +++ b/apps/meseeks/schemas/userSchema.tsx @@ -3,26 +3,15 @@ import { z } from 'zod/v3'; export const userSchema = z.object({ authUserId: z.string().optional(), - name: z.string().optional(), - image: z.string().optional(), + name: z.string().optional().describe('Display name.'), + image: z.string().optional().describe('Profile image URL.'), email: z.string().optional(), emailVerificationTime: z.number().optional(), phone: z.string().optional(), phoneVerificationTime: z.number().optional(), - isAnonymous: z.literal(false).default(false), - verificationLevel: z.enum(['orb', 'device']).optional(), - walletAddress: z.string().optional(), // TODO: write a validator - walletChain: z.enum(['worldchain']).optional(), - isReady: z.boolean().default(false), - balanceUSD: z.bigint().default(0n), - isFounder: z.boolean().default(false), - initialTaskId: zid('tasks').optional(), -}); - -export const userPreferencesSchema = z.object({ - owner: zid('users'), - key: z.string(), - value: z.any(), + isAnonymous: z.boolean().default(false), + energyBalance: z.bigint().default(0n), + root: zid('files').optional(), }); export const userRequestKeySchema = z.enum([ @@ -36,5 +25,5 @@ export const userRequestSchema = z.object({ owner: zid('users'), key: userRequestKeySchema, message: z.string().min(1).max(1000), - context: z.record(z.any()).optional(), + context: z.record(z.unknown()).optional(), }); diff --git a/apps/meseeks/scripts/preview-env.ts b/apps/meseeks/scripts/preview-env.ts index 90e2cc5b..f5794226 100644 --- a/apps/meseeks/scripts/preview-env.ts +++ b/apps/meseeks/scripts/preview-env.ts @@ -2,7 +2,7 @@ import { spawnSync, type SpawnSyncOptions } from 'node:child_process'; import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; const appPackageName = '@meseeks/app'; -const defaultPreviewRun = 'internal.seed._all'; +const defaultPreviewRun = 'none'; export const envLocalFile = '.env.local'; diff --git a/apps/meseeks/scripts/preview.ts b/apps/meseeks/scripts/preview.ts index 94ae7fc1..940e9446 100644 --- a/apps/meseeks/scripts/preview.ts +++ b/apps/meseeks/scripts/preview.ts @@ -31,10 +31,8 @@ function main() { const deploymentRef = previewRef(previewName); const createdDeployment = selectPreviewDeployment(previewName, deploymentRef); const entries = writePreviewEnv(previewName, deploymentRef); + ensurePreviewDeploymentEnv(entries, deploymentRef); const env = previewCommandEnv(entries); - - runConvex(['codegen'], { env }); - const deployArgs = ['deploy', '--preview-name', previewName, '--env-file', envLocalFile, '--codegen', 'disable']; const previewRun = shouldRunPreviewSeed(createdDeployment, entries, deploymentRef) ? getPreviewRun(entries) @@ -42,13 +40,17 @@ function main() { const deployOutput = runConvexDeploy(deployArgs, { env }); updatePreviewEnvFromDeployOutput(entries, deployOutput); + + let deployedEntries = loadEnvLocal(); + assertPreviewDeployment(deployedEntries, deploymentRef); + runConvex(['codegen'], { env: previewCommandEnv(deployedEntries) }); + if (previewRun) { - runConvex(['run', previewRun], { env }); + runConvex(['run', previewRun], { env: previewCommandEnv(deployedEntries) }); markPreviewSeeded(deploymentRef); + deployedEntries = loadEnvLocal(); } - const deployedEntries = loadEnvLocal(); - assertPreviewDeployment(deployedEntries, deploymentRef); if (!shouldStartDevServer) return; run('bun', ['run', 'dev:web'], { env: previewCommandEnv(deployedEntries) }); @@ -94,6 +96,17 @@ function markPreviewSeeded(deploymentRef: string) { writeDotenv(envLocalFile, entries); } +function ensurePreviewDeploymentEnv(entries: Map, deploymentRef: string) { + const rootPrefix = entries.get('OBJECT_STORAGE_ROOT_PREFIX'); + if (!rootPrefix) throw new Error(`Missing OBJECT_STORAGE_ROOT_PREFIX in ${envLocalFile}.`); + + const deploymentSelector = `${convexProjectSelector}:${deploymentRef}`; + console.log(`Setting Convex preview Object Storage root prefix to ${rootPrefix}`); + runConvex(['env', 'set', '--deployment', deploymentSelector, 'OBJECT_STORAGE_ROOT_PREFIX', rootPrefix], { + env: previewCommandEnv(entries), + }); +} + function updatePreviewEnvFromDeployOutput(entries: Map, deployOutput: string) { const cloudUrl = extractDeployedCloudUrl(deployOutput); if (!cloudUrl) return; @@ -121,6 +134,7 @@ function writePreviewEnv(previewName: string, deploymentRef: string) { entries.set('CONVEX_PREVIEW_NAME', previewName); entries.set('CONVEX_PREVIEW_REF', deploymentRef); + entries.set('OBJECT_STORAGE_ROOT_PREFIX', deploymentRef); entries.set('VERCEL_ENV', 'preview'); entries.set('VERCEL_GIT_COMMIT_REF', previewName); removePreviewUnsafeEntries(entries); diff --git a/apps/meseeks/skills/builtIn/askForClarification.ts b/apps/meseeks/skills/builtIn/askForClarification.ts deleted file mode 100644 index acf703d1..00000000 --- a/apps/meseeks/skills/builtIn/askForClarification.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { z } from 'zod/v3'; -import { defineSkill, ExecutionResult, ToolExecution } from '../defineSkill'; - -export const askForClarification = defineSkill({ - preApprovedCost: 0n, - description: - 'Before executing a task, make sure you are at least 80% sure of the user intention for the task. Use this tool to ask for user clarification.', - parameters: z.object({ - message: z.string().describe('The message to send to the user in MDX format.'), - }), - knownReactions: [], - use: - (execution: ToolExecution) => - async (args): Promise => { - // - return { - text: args.message, - reactions: execution.skill.knownReactions, - }; - }, -}); diff --git a/apps/meseeks/skills/builtIn/cancelSchedule.ts b/apps/meseeks/skills/builtIn/cancelSchedule.ts deleted file mode 100644 index 296efe99..00000000 --- a/apps/meseeks/skills/builtIn/cancelSchedule.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { z } from 'zod/v3'; -import { internal } from 'convex/_generated/api'; -import { zid } from 'convex-helpers/server/zod3'; -import { messageFrom } from 'lib/errors'; -import { defineSkill, ExecutionResult, ToolExecution } from '../defineSkill'; - -export const cancelSchedule = defineSkill({ - preApprovedCost: 0n, - description: 'Cancel an existing schedule.', - parameters: z.object({ - scheduleId: z.string().describe('The ID of the schedule to cancel'), - }), - knownReactions: [ - { - skillKey: 'iterate', - args: {}, - condition: 'companion', - }, - ], - use: - (execution: ToolExecution) => - async (args): Promise => { - // - try { - // - await execution.ctx.runMutation(internal.schedules._cancel, { - scheduleId: zid('schedules').parse(args.scheduleId), - }); - - return { - reactions: execution.skill.knownReactions, - }; - // - } catch (error) { - // - throw new Error(messageFrom(error)); - } - }, -}); diff --git a/apps/meseeks/skills/builtIn/createSkill.ts b/apps/meseeks/skills/builtIn/createSkill.ts deleted file mode 100644 index 4f6beef9..00000000 --- a/apps/meseeks/skills/builtIn/createSkill.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { z } from 'zod/v3'; -import { internal } from 'convex/_generated/api'; -import { asBigInt } from 'lib/money'; -import { stringToZod } from 'lib/zodToString'; -import { decisionConfigSchema, httpConfigSchema, newSkillSchema, simplifiedSkillSchema } from 'schemas/skillSchema'; -import { defineSkill, ExecutionResult, ToolExecution } from '../defineSkill'; - -const DEFAULT_PRE_APPROVED_COST = asBigInt({ dollars: 0.2 }); - -export const createSkill = defineSkill({ - preApprovedCost: 'none', - description: 'Learn a new skill.', - parameters: z.object({ - skill: simplifiedSkillSchema, - }), - knownReactions: [ - { - skillKey: 'learnSkill', - args: {}, - condition: 'companion', - }, - ], - use: - (execution: ToolExecution) => - async (args): Promise => { - // - console.debug('learning skill', args.skill); - ensureInputSchemaIsValid(args.skill.inputSchema); - - const { skill } = args; - - await execution.ctx.runMutation(internal.skills._create, { - userId: execution.task.owner, - skill: newSkillSchema.parse({ - key: skill.key, - description: skill.description, - kind: skill.kind, - inputSchema: skill.inputSchema, - preApprovedCost: skill.isSafe ? DEFAULT_PRE_APPROVED_COST : 'none', - // TODO: make sure to add `iterate` to the knownReactions - knownReactions: createKnownReactions(skill.knownReactions), - config: createConfig(skill), - cost: skill.kind === 'hard' ? 0n : 'dynamic', - owner: execution.task.owner, - author: execution.action._id, - }), - }); - - // enable the newly created skill - await execution.ctx.runMutation(internal.skills._enableSkill, { - userId: execution.task.owner, - skillKey: skill.key, - }); - - // make sure it's available for the task - await execution.ctx.runMutation(internal.tasks._addAvailableSkill, { - taskId: execution.task._id, - skillKey: skill.key, - }); - - console.debug('skill created', skill); - - return { - text: `✅ ${skill.kind === 'hard' ? 'Hard' : 'Soft'} skill '${skill.key}' learned.`, - reactions: execution.skill.knownReactions, - }; - }, -}); - -export function ensureInputSchemaIsValid(inputSchema: string) { - // - try { - stringToZod(inputSchema); - return true; - } catch { - throw new Error(`Invalid input schema: ${inputSchema}`); - } -} - -export function createKnownReactions(skillReactions?: string[]) { - // - const baseReactions = - skillReactions?.map((key) => ({ - skillKey: key, - args: {}, - condition: 'any', - })) ?? []; - - if (baseReactions.map((r) => r.skillKey).includes('iterate')) { - return baseReactions; - } - - return baseReactions.concat({ - skillKey: 'iterate', - args: {}, - condition: 'companion', - }); -} - -export function createConfig(skill: z.infer) { - // - switch (skill.kind) { - // - case 'soft': - return decisionConfigSchema.parse({ - model: 'auto', - instructions: skill.config.instructions, - temperature: skill.config.temperature, - availableSkills: skill.config.availableSkills, - historyMode: 'since last instructed', - }); - - case 'hard': - return httpConfigSchema.parse({ - url: skill.config.url, - method: skill.config.method, - headers: skill.config.headers ?? {}, - paramMappings: skill.config.paramMappings, - body: skill.config.body, - }); - } -} diff --git a/apps/meseeks/skills/builtIn/createSubtask.ts b/apps/meseeks/skills/builtIn/createSubtask.ts deleted file mode 100644 index 9223bec0..00000000 --- a/apps/meseeks/skills/builtIn/createSubtask.ts +++ /dev/null @@ -1,33 +0,0 @@ -// import { z } from 'zod'; -// import { internal } from 'convex/_generated/api'; -// import { defineSkill, ExecutionResult, ToolExecution } from '../defineSkill'; - -// export const createSubtask = defineSkill({ -// preApprovedCost: 'none', -// description: 'Create a subtask', -// parameters: z.object({ -// title: z.string().max(60).describe('The title of the subtask. Max 60 characters.'), -// instructions: z -// .string() -// .describe( -// 'Instructions for the subtask. Make sure to add all required details so another Meseeks can handle it properly. Think through your current context carefully and send a complete and structured message.', -// ), -// }), -// knownReactions: [], -// use: -// (execution: ToolExecution) => -// async (args): Promise => { -// // -// await execution.ctx.runMutation(internal.tasks._add, { -// parentId: execution.task._id, -// author: execution.action?._id, -// owner: execution.task.owner, -// title: args.title, -// instructions: args.instructions, -// }); - -// return { -// reactions: execution.skill.knownReactions, -// }; -// }, -// }); diff --git a/apps/meseeks/skills/builtIn/decreaseBudget.ts b/apps/meseeks/skills/builtIn/decreaseBudget.ts deleted file mode 100644 index 54f5bc66..00000000 --- a/apps/meseeks/skills/builtIn/decreaseBudget.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { z } from 'zod/v3'; -import { internal } from 'convex/_generated/api'; -import { asDollars } from 'lib/money'; -import { defineSkill, ExecutionResult, ToolExecution } from '../defineSkill'; - -export const decreaseBudget = defineSkill({ - preApprovedCost: 'none', - description: 'Remove energy from the task.', - parameters: z.object({ - amount: z.bigint().min(0n).describe('The amount of funds to remove, in energy.'), - }), - knownReactions: [], - use: - (execution: ToolExecution) => - async (args): Promise => { - // - try { - await execution.ctx.runMutation(internal.tasks._removeFunds, { - taskId: execution.task._id, - amount: args.amount, - }); - - return { - text: `decreased energy by ${asDollars({ bigInt: args.amount })}`, - reactions: [], - }; - // - } catch { - // perform() will resolve as failed with that message - throw new Error('Failed to remove energy from task.'); - } - }, -}); diff --git a/apps/meseeks/skills/builtIn/discard.ts b/apps/meseeks/skills/builtIn/discard.ts deleted file mode 100644 index e03d657d..00000000 --- a/apps/meseeks/skills/builtIn/discard.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { z } from 'zod/v3'; -import { internal } from 'convex/_generated/api'; -import { defineSkill, ExecutionResult, ToolExecution } from '../defineSkill'; - -export const discard = defineSkill({ - preApprovedCost: 'none', - description: 'Discard the current task by marking it as done without learning.', - parameters: z.object({ - reasoning: z.string().optional().describe('A short explanation for discarding the task.'), - }), - knownReactions: [], - use: - (execution: ToolExecution) => - async (args): Promise => { - // - await execution.ctx.runMutation(internal.tasks._setStatus, { - taskId: execution.task._id, - newStatus: 'discarded', - }); - - return { - text: args.reasoning ?? undefined, - reactions: execution.skill.knownReactions, - }; - }, -}); diff --git a/apps/meseeks/skills/builtIn/divide.ts b/apps/meseeks/skills/builtIn/divide.ts deleted file mode 100644 index d92a7bb6..00000000 --- a/apps/meseeks/skills/builtIn/divide.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { z } from 'zod/v3'; -import { defineSkill, ExecutionResult, ToolExecution } from '../defineSkill'; - -export const divide = defineSkill({ - preApprovedCost: 0n, - description: 'Divide N numbers', - parameters: z.object({ - A: z.number().describe('The dividend.'), - B: z.number().describe('The divisor.'), - }), - knownReactions: [ - { - skillKey: 'iterate', - args: {}, - condition: 'companion', - }, - ], - use: - (execution: ToolExecution) => - async (args): Promise => { - // - return { - text: `${args.A} / ${args.B} = ${args.A / args.B}`, - reactions: execution.skill.knownReactions, - }; - }, -}); diff --git a/apps/meseeks/skills/builtIn/done.ts b/apps/meseeks/skills/builtIn/done.ts deleted file mode 100644 index 7c253c0e..00000000 --- a/apps/meseeks/skills/builtIn/done.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { z } from 'zod/v3'; -import { defineSkill, ExecutionResult, ToolExecution } from '../defineSkill'; - -export const done = defineSkill({ - preApprovedCost: 0n, - description: 'Stop iterating.', - parameters: z.object({ - message: z.string().optional().describe('An optional (final) message to the user. Max 100 characters.'), - reason: z.enum([ - 'resolved', // - // 'running out of budget', - 'blocked', - ]), - }), - knownReactions: [], - use: - (execution: ToolExecution) => - async (args): Promise => { - // - console.debug('done skill completed', { - actionId: execution.action._id, - reason: args.reason, - hasMessage: Boolean(args.message), - }); - - return { - reactions: execution.skill.knownReactions, - }; - }, -}); diff --git a/apps/meseeks/skills/builtIn/getSkillDetails.ts b/apps/meseeks/skills/builtIn/getSkillDetails.ts deleted file mode 100644 index 249e7a19..00000000 --- a/apps/meseeks/skills/builtIn/getSkillDetails.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { z } from 'zod/v3'; -import { internal } from 'convex/_generated/api'; -import { skillSchema } from 'schemas/skillSchema'; -import { asDollars } from 'lib/money'; -import { defineSkill, ExecutionResult, ToolExecution } from '../defineSkill'; - -export const getSkillDetails = defineSkill({ - preApprovedCost: 0n, - description: 'Get detailed information about a specific skill by its key.', - parameters: z.object({ - skillKey: z.string().describe('The key of the skill to retrieve details for'), - }), - knownReactions: [ - { - skillKey: 'learnSkill', - args: {}, - condition: 'companion', - }, - ], - use: - (execution: ToolExecution) => - async (args): Promise => { - // - const skill = await execution.ctx.runQuery(internal.skills._findOne, { - key: args.skillKey, - owner: execution.task.owner, - }); - - if (!skill) { - return { - text: `❌ Skill '${args.skillKey}' not found.`, - reactions: execution.skill.knownReactions, - }; - } - - return { - text: JSON.stringify({ - ...skill, - cost: renderCost(skill), - preApprovedCost: renderPreApprovedCost(skill), - }), - reactions: execution.skill.knownReactions, - }; - }, -}); - -function renderCost(skill: z.infer) { - // - if (skill.cost === 'dynamic') return 'Cost depends on selected task intelligence and token usage'; - if (skill.cost === 0n) return 'Free'; - - return `${asDollars({ bigInt: skill.cost, precision: 6 })} energy per use`; -} - -function renderPreApprovedCost(skill: z.infer) { - // - if (skill.preApprovedCost === 'none') return 'none'; - - return `automatically authorized up to ${asDollars({ bigInt: skill.preApprovedCost, precision: 6 })} energy per use`; -} diff --git a/apps/meseeks/skills/builtIn/increaseBudget.ts b/apps/meseeks/skills/builtIn/increaseBudget.ts deleted file mode 100644 index 3bb15ed7..00000000 --- a/apps/meseeks/skills/builtIn/increaseBudget.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { z } from 'zod/v3'; -import { internal } from 'convex/_generated/api'; -import { asDollars } from 'lib/money'; -import { defineSkill, ExecutionResult, ToolExecution } from '../defineSkill'; - -export const increaseBudget = defineSkill({ - preApprovedCost: 'none', - description: 'Increase the energy budget of the task.', - parameters: z.object({ - amount: z.bigint().min(0n).describe('The amount of funds to add, in energy.'), - shouldIterate: z.boolean().optional().default(true).describe('Whether to react with iterate() or not.'), - }), - knownReactions: [], - use: - (execution: ToolExecution) => - async (args): Promise => { - // - const instruct = { - skillKey: 'instruct' as const, - args: {}, - condition: 'owner' as const, - }; - - try { - await execution.ctx.runMutation(internal.tasks._increaseBudget, { - taskId: execution.task._id, - amount: args.amount, - }); - - return { - text: `energy budget increased by ${asDollars({ bigInt: args.amount })}`, - reactions: args.shouldIterate ? [instruct] : [], - }; - // - } catch { - // perform() will resolve as failed with that message - throw new Error('Insufficient account funds to increase task energy.'); - } - }, -}); diff --git a/apps/meseeks/skills/builtIn/index.ts b/apps/meseeks/skills/builtIn/index.ts deleted file mode 100644 index 4b5836dc..00000000 --- a/apps/meseeks/skills/builtIn/index.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { askForClarification } from './askForClarification'; -import { cancelSchedule } from './cancelSchedule'; -import { createSkill } from './createSkill'; -import { decreaseBudget } from './decreaseBudget'; -import { discard } from './discard'; -import { done } from './done'; -import { getSkillDetails } from './getSkillDetails'; -import { increaseBudget } from './increaseBudget'; -import { justSay } from './justSay'; -import { lookAtMe } from './lookAtMe'; -import { reason } from './reason'; -import { render } from './render'; -import { reopen } from './reopen'; -import { requestBudget } from './requestBudget'; -import { requestIteration } from './requestIteration'; -import { resolve } from './resolve'; -import { say } from './say'; -import { schedule } from './schedule'; -import { scheduledIteration } from './scheduledIteration'; -import { setUserInfo } from './setUserInfo'; -import { stop } from './stop'; -import { updateInstructions } from './updateInstructions'; -import { updateSkill } from './updateSkill'; - -export const _builtInSkills = { - // - // loop entry - askForClarification, - updateInstructions, - - // math - // sum, - // multiply, - // divide, - // subtract, - - // lifecycle - say, - render, - justSay, // TODO: this is a workaround to avoid reactions on the onboarding - done, - stop, - reason, - reopen, - increaseBudget, - decreaseBudget, - resolve, - requestBudget, - discard, - requestIteration, - lookAtMe, - - // scheduling - schedule, - scheduledIteration, - cancelSchedule, - - // skills - createSkill, - updateSkill, - getSkillDetails, - - // user info - setUserInfo, -}; - -// moveTask, -// createSubtask, diff --git a/apps/meseeks/skills/builtIn/justSay.ts b/apps/meseeks/skills/builtIn/justSay.ts deleted file mode 100644 index 0aab1623..00000000 --- a/apps/meseeks/skills/builtIn/justSay.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { z } from 'zod/v3'; -import { defineSkill, ExecutionResult, ToolExecution } from '../defineSkill'; - -export const justSay = defineSkill({ - preApprovedCost: 0n, - description: 'Send a text message.', - parameters: z.object({ - message: z.string().describe('The message in MDX format.'), - }), - knownReactions: [], - use: - (execution: ToolExecution) => - async (args): Promise => { - // - return { - text: args.message, - reactions: execution.skill.knownReactions, - }; - }, - hidden: true, -}); diff --git a/apps/meseeks/skills/builtIn/lookAtMe.ts b/apps/meseeks/skills/builtIn/lookAtMe.ts deleted file mode 100644 index 06d1078c..00000000 --- a/apps/meseeks/skills/builtIn/lookAtMe.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { z } from 'zod/v3'; -import { defineSkill, ExecutionResult } from '../defineSkill'; - -export const lookAtMe = defineSkill({ - preApprovedCost: 0n, - description: 'Onboard a new user with welcome messages and collect their personal information.', - parameters: z.object({}), - knownReactions: [ - { - skillKey: 'justSay', - args: { - message: '## Welcome to Meseeks! 👋', - }, - condition: 'any', - }, - { - skillKey: 'justSay', - args: { - message: - 'Meseeks is an open-source AI companion ("agents" are so 2024) that runs autonomously and learns from your decisions.', - }, - condition: 'any', - }, - { - skillKey: 'justSay', - args: { - message: - 'It enables businesses to run with minimal human supervision by combining AI decision-making ("soft skills") with HTTP/MCP integrations ("hard skills").', - }, - condition: 'any', - }, - { - skillKey: 'justSay', - args: { - message: - "### All while maintaining:\n\n🔘 **transparency** (you pay what it costs, not a penny more!)\n🔘 **accountability** (every Meseeks re-action is traceable to a human action)\n🔘 **trust** (it's safe, open and verifiable)\n🔘 **control** (system-enforced rules, autonomy grows relative to your trust)", - }, - condition: 'any', - }, - { - skillKey: 'justSay', - args: { - message: - 'In simple terms, it feels like you just hired someone. A very capable, fast learner and infinitely scalable new hire for your business — or life.', - }, - condition: 'any', - }, - { - skillKey: 'justSay', - args: { - message: - '### ⚠️ This is a research preview\n\n- Do not share sensitive, confidential, or personal data with Meseeks. Treat all inputs as potentially public and non‑secure.\n- Maintain your own backups. Do not rely on Meseeks as the sole repository for important information or content.\n- Validate critical outputs. Responses may contain errors, be incomplete, or become outdated quickly. Always verify crucial information independently before acting on it.\n-Expect things to change fast and break.\n- Use at your own risk. By continuing to use Meseeks during this research phase, you acknowledge these limitations and agree that Meseeks shall not be liable for any loss, damage, or harm arising from your reliance on it.', - }, - condition: 'any', - }, - { - skillKey: 'justSay', - args: { - message: - '### Getting Started\n\nAt the beginning, it requires some energy from you. But the more you use it, the more you trust it, and the more you delegate to it.\n\nMeseeks is still very early, but already very capable. Start by giving me a task and see what happens! 🚀', - }, - condition: 'any', - }, - { - skillKey: 'justSay', - args: { - message: - "### Let me get to know you better\n\nTo provide you with the best assistance, I'd like to learn about you. Could you tell me a bit about yourself? Things like your name, background, interests, profession, or anything else you'd like me to know?", - }, - condition: 'any', - }, - - { - skillKey: 'done', - args: { - message: 'Welcome aboard!', - reason: 'resolved', - }, - condition: 'any', - }, - ], - use: (execution) => async (): Promise => { - // - return { - text: 'Welcome aboard!', - reactions: execution.skill.knownReactions, - }; - }, - hidden: true, -}); diff --git a/apps/meseeks/skills/builtIn/moveTask.ts b/apps/meseeks/skills/builtIn/moveTask.ts deleted file mode 100644 index aa4277cb..00000000 --- a/apps/meseeks/skills/builtIn/moveTask.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { zid } from 'convex-helpers/server/zod3'; -import { z } from 'zod/v3'; -import { internal } from 'convex/_generated/api'; -import { defineSkill, ExecutionResult, ToolExecution } from '../defineSkill'; - -export const moveTask = defineSkill({ - preApprovedCost: 'none', - description: 'Move the task to a new parent', - parameters: z.object({ - taskId: z.string().describe('The task id to be moved.'), - newParentId: z - .union([z.string(), z.literal('inbox')]) - .describe( - 'The new parent id for the task. Use "inbox" to move the task to the Inbox (aka root, no parent).', - ), - }), - knownReactions: [], - use: - (execution: ToolExecution) => - async (args): Promise => { - // - const taskId = zid('tasks').parse(args.taskId); - const newParentId = args.newParentId === 'inbox' ? undefined : zid('tasks').parse(args.newParentId); - - await execution.ctx.runMutation(internal.tasks._move, { - taskId, - newParentId, - }); - - return { - reactions: execution.skill.knownReactions, - }; - }, -}); diff --git a/apps/meseeks/skills/builtIn/multiply.ts b/apps/meseeks/skills/builtIn/multiply.ts deleted file mode 100644 index 8d30f7c3..00000000 --- a/apps/meseeks/skills/builtIn/multiply.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { z } from 'zod/v3'; -import { defineSkill, ExecutionResult } from '../defineSkill'; - -export const multiply = defineSkill({ - preApprovedCost: 0n, - description: 'Multiply N numbers. ***NOTE: the numbers must be passed as a NUMBER ARRAY - DO NOT USE STRINGS***', - parameters: z.object({ - numbers: z.array(z.number()).describe('The numbers to multiply.'), - }), - knownReactions: [ - { - skillKey: 'iterate', - args: {}, - condition: 'companion', - }, - ], - use: - (execution) => - async (args): Promise => { - // - return { - text: `${args.numbers.join(' * ')} = ${args.numbers.reduce((acc, curr) => acc * curr, 1)}`, - reactions: execution.skill.knownReactions, - }; - }, -}); diff --git a/apps/meseeks/skills/builtIn/reason.ts b/apps/meseeks/skills/builtIn/reason.ts deleted file mode 100644 index 6434e224..00000000 --- a/apps/meseeks/skills/builtIn/reason.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { z } from 'zod/v3'; -import { defineSkill, ExecutionResult, ToolExecution } from '../defineSkill'; - -export const reason = defineSkill({ - preApprovedCost: 0n, - description: - 'Reason (think to yourself - *not visible to the user*). Scientifically proven to increase the quality of the next action.', - parameters: z.object({ - reasoning: z.string().describe('The reasoning in MDX format.'), - }), - knownReactions: [ - { - skillKey: 'iterate', - args: {}, - condition: 'companion', - }, - ], - use: - (execution: ToolExecution) => - async (args): Promise => { - // - return { - text: args.reasoning, - reactions: execution.skill.knownReactions, - }; - }, -}); diff --git a/apps/meseeks/skills/builtIn/render.ts b/apps/meseeks/skills/builtIn/render.ts deleted file mode 100644 index 6c2c55f9..00000000 --- a/apps/meseeks/skills/builtIn/render.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { z } from 'zod/v3'; -import { internal } from 'convex/_generated/api'; -import { defineSkill, ExecutionResult, ToolExecution } from '../defineSkill'; - -export const render = defineSkill({ - preApprovedCost: 0n, - description: 'Render a React component.', - parameters: z.object({ - code: z.string().max(100000).describe('The React component code to render.'), - }), - knownReactions: [ - { - skillKey: 'iterate', - args: {}, - condition: 'companion', - }, - ], - use: - (execution: ToolExecution) => - async (args): Promise => { - // - // TODO: remove after new reactor (that only takes action ctx) - if (!('runAction' in execution.ctx)) { - throw new Error('Render can only run from an action context.'); - } - - const transpiledCode = await execution.ctx.runAction(internal.babel._transpileCode, { - code: args.code, - }); - - return { - text: transpiledCode, - reactions: execution.skill.knownReactions, - }; - }, - priority: 100, -}); diff --git a/apps/meseeks/skills/builtIn/reopen.ts b/apps/meseeks/skills/builtIn/reopen.ts deleted file mode 100644 index 3b29db71..00000000 --- a/apps/meseeks/skills/builtIn/reopen.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { z } from 'zod/v3'; -import { internal } from 'convex/_generated/api'; -import { defineSkill, ExecutionResult } from '../defineSkill'; - -export const reopen = defineSkill({ - preApprovedCost: 'none', - description: 'Re-open a task that was previously marked as done.', - parameters: z.object({}), - knownReactions: [], - use: (execution) => async (): Promise => { - // - await execution.ctx.runMutation(internal.tasks._setStatus, { - taskId: execution.task._id, - newStatus: 'idle', - }); - - return { - reactions: execution.skill.knownReactions, - }; - }, -}); diff --git a/apps/meseeks/skills/builtIn/requestBudget.ts b/apps/meseeks/skills/builtIn/requestBudget.ts deleted file mode 100644 index 8980a3de..00000000 --- a/apps/meseeks/skills/builtIn/requestBudget.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { z } from 'zod/v3'; -import { asBigInt } from 'lib/money'; -import { defineSkill, ExecutionResult, ToolExecution } from '../defineSkill'; - -export const requestBudget = defineSkill({ - // - preApprovedCost: asBigInt({ dollars: 0.01 }), - description: 'Request energy increase for the task.', - parameters: z.object({ - estimatedCost: z.bigint().describe('The estimated cost for the failed action, in energy'), - previousActionKey: z.string().describe('The key of the previous action that failed'), - }), - knownReactions: [], - use: - (execution: ToolExecution) => - async (args): Promise => { - // - console.debug('requesting task budget increase', { - taskId: execution.task._id, - actionId: execution.action._id, - estimatedCost: args.estimatedCost.toString(), - previousActionKey: args.previousActionKey, - }); - - return { - text: `This task needs more energy to continue.\n\n
`, - reactions: [], - }; - }, -}); diff --git a/apps/meseeks/skills/builtIn/requestIteration.ts b/apps/meseeks/skills/builtIn/requestIteration.ts deleted file mode 100644 index 90b2721b..00000000 --- a/apps/meseeks/skills/builtIn/requestIteration.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { z } from 'zod/v3'; -import { defineSkill, ExecutionResult } from '../defineSkill'; - -export const requestIteration = defineSkill({ - preApprovedCost: 0n, - description: 'Request a new iteration of the task.', - parameters: z.object({}), - knownReactions: [ - { - skillKey: 'instruct', - args: {}, - condition: 'owner', - }, - ], - use: (execution) => async (): Promise => { - // - return { - reactions: execution.skill.knownReactions, - }; - }, -}); diff --git a/apps/meseeks/skills/builtIn/resolve.ts b/apps/meseeks/skills/builtIn/resolve.ts deleted file mode 100644 index febced8c..00000000 --- a/apps/meseeks/skills/builtIn/resolve.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { z } from 'zod/v3'; -import { internal } from 'convex/_generated/api'; -import { defineSkill, ExecutionResult, ToolExecution } from '../defineSkill'; - -export const resolve = defineSkill({ - preApprovedCost: 'none', - description: 'Mark the task as done, and learn!', - parameters: z.object({ - reasoning: z.string().optional().describe('A short explanation for resolving the task.'), - }), - knownReactions: [ - // { - // skillKey: 'learn', - // args: {}, - // condition: 'any', - // }, - ], - use: - (execution: ToolExecution) => - async (args): Promise => { - // - await execution.ctx.runMutation(internal.tasks._setStatus, { - taskId: execution.task._id, - newStatus: 'done', - }); - - return { - text: args.reasoning ?? undefined, - reactions: execution.skill.knownReactions, - }; - }, -}); diff --git a/apps/meseeks/skills/builtIn/say.ts b/apps/meseeks/skills/builtIn/say.ts deleted file mode 100644 index aee34c01..00000000 --- a/apps/meseeks/skills/builtIn/say.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { z } from 'zod/v3'; -import { defineSkill, ExecutionResult, ToolExecution } from '../defineSkill'; - -export const say = defineSkill({ - preApprovedCost: 0n, - description: 'Sends a text message.', - parameters: z.object({ - message: z.string().describe('The message in MDX format.'), - }), - knownReactions: [ - { - skillKey: 'instruct', - args: {}, - condition: 'owner', - }, - { - skillKey: 'iterate', - args: {}, - condition: 'companion', - }, - ], - use: - (execution: ToolExecution) => - async (args): Promise => { - // - return { - text: args.message, - reactions: execution.skill.knownReactions, - }; - }, - priority: 0, -}); diff --git a/apps/meseeks/skills/builtIn/schedule.ts b/apps/meseeks/skills/builtIn/schedule.ts deleted file mode 100644 index 7dac0e79..00000000 --- a/apps/meseeks/skills/builtIn/schedule.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { z } from 'zod/v3'; -import { internal } from 'convex/_generated/api'; -import { computeNextRun } from 'lib/cron'; -import { formatScheduledTime } from 'lib/date'; -import { timeZoneSchema } from 'schemas/scheduleSchema'; -import { defineSkill, ExecutionResult, ToolExecution } from '../defineSkill'; - -export const schedule = defineSkill({ - preApprovedCost: 0n, - description: - 'Schedule a iteration to run at specific times. Once or repeatedly. For recurring schedules, you must provide a cron expression. For one-time schedules, you must provide a delay in seconds or an specific time.', - parameters: z.object({ - scheduleType: z.enum(['one-time', 'recurring']).describe('Type of schedule'), - timeZone: timeZoneSchema, - delaySeconds: z - .number() - .optional() - .describe( - 'Number of seconds from now to execute (**for one-time schedules only**). Example: `1800` for 30 minutes', - ), - scheduledAt: z - .string() - .optional() - .describe( - 'ISO8601 datetime string for when to execute (**for one-time schedules only**). Example: `2024-12-25T15:30:00Z`', - ), - cronExpression: z - .string() - .optional() - .describe( - 'Cron expression for recurring execution (**required for recurring schedules**). Example: "0 9 * * 1" for every Monday at 9 AM', - ), - instructions: z - .string() - .optional() - .describe( - 'Specific instructions for what to do when this schedule triggers (e.g., "Generate daily sales report", "Send weekly project update"). This provides context to the model when the schedule runs.', - ), - }), - knownReactions: [ - { - skillKey: 'iterate', - args: {}, - condition: 'companion', - }, - ], - use: - (execution: ToolExecution) => - async (args): Promise => { - // - const skillKey = 'scheduledIteration'; - - // Create the schedule (this handles all validation and business logic) - await execution.ctx.runMutation(internal.schedules._create, { - taskId: execution.task._id, - owner: execution.task.owner, - author: execution.action._id, - skillKey, - args: { - scheduleType: args.scheduleType, - instructions: args.instructions, - }, - depth: execution.action.depth + 1, - scheduleType: args.scheduleType, - timeZone: args.timeZone, - delaySeconds: args.delaySeconds, - scheduledAt: args.scheduledAt, - cronExpression: args.cronExpression, - }); - - // Generate response message - if (args.scheduleType === 'one-time') { - // - let scheduledTimestamp: Date; - if (args.delaySeconds) { - scheduledTimestamp = new Date(Date.now() + args.delaySeconds * 1000); - } else { - scheduledTimestamp = new Date(args.scheduledAt!); - } - - const description = formatScheduledTime(scheduledTimestamp, args.timeZone); - return { - text: `📅 Scheduled to iterate ${description}`, - reactions: execution.skill.knownReactions, - }; - // - } else { - // - const nextRun = computeNextRun(args.cronExpression!, args.timeZone); - return { - text: [ - `📅 Scheduled to iterate recurrently (rule ${args.cronExpression}).`, - `Next run will be ${formatScheduledTime(nextRun, args.timeZone)}`, - ].join(' '), - reactions: execution.skill.knownReactions, - }; - } - }, -}); diff --git a/apps/meseeks/skills/builtIn/scheduledIteration.ts b/apps/meseeks/skills/builtIn/scheduledIteration.ts deleted file mode 100644 index eb31be51..00000000 --- a/apps/meseeks/skills/builtIn/scheduledIteration.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { z } from 'zod/v3'; -import { defineSkill, ExecutionResult, ToolExecution } from '../defineSkill'; - -export const scheduledIteration = defineSkill({ - preApprovedCost: 0n, - description: - 'Execute a scheduled iteration - this skill is automatically invoked when a previously scheduled time arrives.', - parameters: z.object({ - scheduleType: z.enum(['one-time', 'recurring']).describe('Type of schedule that triggered this iteration'), - instructions: z.string().optional().describe('Specific instructions for what to do when this schedule runs'), - }), - knownReactions: [ - { - skillKey: 'iterate', - args: {}, - condition: 'companion', - }, - ], - use: - (execution: ToolExecution) => - async (args): Promise => { - // - const scheduleTypeText = args.scheduleType === 'one-time' ? 'one-time' : 'recurring'; - - // Create context message for the model - this will appear in message history - let contextMessage = `A scheduled ${scheduleTypeText} iteration was previously set up to run at this time.`; - - if (args.instructions) { - contextMessage += ` Instructions for this scheduled run: ${args.instructions}`; - } - - contextMessage += ' Proceed with the task accordingly.'; - - return { - text: contextMessage, - reactions: execution.skill.knownReactions, - }; - }, -}); diff --git a/apps/meseeks/skills/builtIn/setUserInfo.ts b/apps/meseeks/skills/builtIn/setUserInfo.ts deleted file mode 100644 index 246c369d..00000000 --- a/apps/meseeks/skills/builtIn/setUserInfo.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { z } from 'zod/v3'; -import { internal } from 'convex/_generated/api'; -import { defineSkill, ExecutionResult, ToolExecution } from '../defineSkill'; - -export const setUserInfo = defineSkill({ - preApprovedCost: 0n, - description: - 'Update the user information stored in preferences. Use this when the user explicitly asks you to remember something. What you pass in as `userInfo` will REPLACE the current user info. So make sure to include the WHOLE THING.', - parameters: z.object({ - userInfo: z.string().describe('The updated user information as a text string'), - }), - knownReactions: [ - { - skillKey: 'iterate', - args: {}, - condition: 'companion', - }, - ], - use: - (execution: ToolExecution) => - async (args): Promise => { - // - await execution.ctx.runMutation(internal.users.preferences._setUserPreference, { - userId: execution.task.owner, - key: 'userInfo', - value: args.userInfo, - }); - - return { - text: `✅ User information updated.`, - reactions: execution.skill.knownReactions, - }; - }, -}); diff --git a/apps/meseeks/skills/builtIn/stop.ts b/apps/meseeks/skills/builtIn/stop.ts deleted file mode 100644 index b3bd7cb4..00000000 --- a/apps/meseeks/skills/builtIn/stop.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { z } from 'zod/v3'; -import { defineSkill, ExecutionResult } from '../defineSkill'; - -export const stop = defineSkill({ - preApprovedCost: 0n, - description: 'Stop the reaction chain.', - parameters: z.object({}), - knownReactions: [], - use: (execution) => async (): Promise => { - // - return { - reactions: execution.skill.knownReactions, - }; - }, -}); diff --git a/apps/meseeks/skills/builtIn/subtract.ts b/apps/meseeks/skills/builtIn/subtract.ts deleted file mode 100644 index 5560a0cb..00000000 --- a/apps/meseeks/skills/builtIn/subtract.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { z } from 'zod/v3'; -import { defineSkill, ExecutionResult, ToolExecution } from '../defineSkill'; - -export const subtract = defineSkill({ - preApprovedCost: 0n, - description: 'Subtract a number from another number', - parameters: z.object({ - from: z.number().describe('The number to subtract from.'), - amount: z.number().describe('The amount to subtract.'), - }), - knownReactions: [ - { - skillKey: 'iterate', - args: {}, - condition: 'companion', - }, - ], - use: - (execution: ToolExecution) => - async (args): Promise => { - // - return { - text: `${args.from} - ${args.amount} = ${args.from - args.amount}`, - reactions: execution.skill.knownReactions, - }; - }, -}); diff --git a/apps/meseeks/skills/builtIn/sum.ts b/apps/meseeks/skills/builtIn/sum.ts deleted file mode 100644 index 75a4feb9..00000000 --- a/apps/meseeks/skills/builtIn/sum.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { z } from 'zod/v3'; -import { defineSkill, ExecutionResult } from '../defineSkill'; - -export const sum = defineSkill({ - preApprovedCost: 0n, - description: 'Sum N numbers', - parameters: z.object({ - numbers: z.array(z.number()).describe('The numbers to sum.'), - }), - knownReactions: [ - { - skillKey: 'iterate', - args: {}, - condition: 'companion', - }, - ], - use: - (execution) => - async (args): Promise => { - // - return { - text: `${args.numbers.join(' + ')} = ${args.numbers.reduce((acc, curr) => acc + curr, 0)}`, - reactions: execution.skill.knownReactions, - }; - }, -}); diff --git a/apps/meseeks/skills/builtIn/updateInstructions.ts b/apps/meseeks/skills/builtIn/updateInstructions.ts deleted file mode 100644 index 464d7cec..00000000 --- a/apps/meseeks/skills/builtIn/updateInstructions.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { z } from 'zod/v3'; -import { internal } from 'convex/_generated/api'; -import { defineSkill, ExecutionResult, ToolExecution } from '../defineSkill'; - -export const updateInstructions = defineSkill({ - preApprovedCost: 0n, - description: 'Updates the task.', - parameters: z.object({ - title: z - .string() - .optional() - .describe('A short title for the task. **Max 60 characters** (will truncate if longer).'), - instructions: z - .string() - .optional() - .describe(`MDX. Add any details on how to handle the task, what should be done, how, references, etc.`), - summary: z - .string() // - .optional() - .describe(`MDX. Add any details on what we have done so far. Bullet points are preferred.`), - availableSkills: z - .array(z.string()) - .max(16) - .optional() - .describe( - 'List of skill keys available for this task. Select up to 16 skills that will be available for this tasks. Make sure to select the most relevant skills for completing this task.', - ), - }), - knownReactions: [ - { - skillKey: 'iterate', - args: {}, - condition: 'companion', - }, - ], - use: - (execution: ToolExecution) => - async (args): Promise => { - // - const MAX_TITLE_LENGTH = 60; - const isTitleTruncated = args.title && args.title.length > MAX_TITLE_LENGTH; - - await execution.ctx.runMutation(internal.tasks._updateInstructions, { - taskId: execution.task._id, - title: isTitleTruncated ? args.title?.slice(0, MAX_TITLE_LENGTH).trim() + '...' : args.title, - instructions: args.instructions, - summary: args.summary, - availableSkills: args.availableSkills, - owner: execution.task.owner, - }); - - return { - text: - args.title && args.title.length > MAX_TITLE_LENGTH - ? `WARNING: Title was truncated to ${MAX_TITLE_LENGTH} characters (from ${args.title.length} characters).` - : undefined, - reactions: execution.skill.knownReactions, - }; - }, -}); diff --git a/apps/meseeks/skills/builtIn/updateSkill.ts b/apps/meseeks/skills/builtIn/updateSkill.ts deleted file mode 100644 index 62405cce..00000000 --- a/apps/meseeks/skills/builtIn/updateSkill.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { z } from 'zod/v3'; -import { internal } from 'convex/_generated/api'; -import { asBigInt } from 'lib/money'; -import { newSkillSchema, simplifiedSkillSchema } from 'schemas/skillSchema'; -import { defineSkill, ExecutionResult, ToolExecution } from '../defineSkill'; -import { createConfig, createKnownReactions, ensureInputSchemaIsValid } from './createSkill'; - -export const updateSkill = defineSkill({ - preApprovedCost: 'none', - description: 'Update details of a skill we already know.', - parameters: z.object({ - skill: simplifiedSkillSchema, - }), - knownReactions: [ - { - skillKey: 'learnSkill', - args: {}, - condition: 'companion', - }, - ], - use: - (execution: ToolExecution) => - async (args): Promise => { - // - console.debug('updating skill', args.skill); - ensureInputSchemaIsValid(args.skill.inputSchema); - - const { skill } = args; - - await execution.ctx.runMutation(internal.skills._update, { - userId: execution.task.owner, - skill: newSkillSchema.parse({ - key: skill.key, - description: skill.description, - kind: skill.kind, - inputSchema: skill.inputSchema, - preApprovedCost: skill.isSafe ? asBigInt({ dollars: 0.05 }) : 'none', - knownReactions: createKnownReactions(skill.knownReactions), - config: createConfig(skill), - cost: skill.kind === 'hard' ? 0n : 'dynamic', - owner: execution.task.owner, - author: execution.action._id, - }), - }); - - // enable the updated skill - await execution.ctx.runMutation(internal.skills._enableSkill, { - userId: execution.task.owner, - skillKey: skill.key, - }); - - // make sure it's available for the task - await execution.ctx.runMutation(internal.tasks._addAvailableSkill, { - taskId: execution.task._id, - skillKey: skill.key, - }); - - console.debug('skill updated', skill); - - const kind = skill.kind === 'hard' ? 'Hard' : 'Soft'; - return { - text: `✍️ ${kind} skill '${skill.key}' updated.`, - reactions: execution.skill.knownReactions, - }; - }, -}); diff --git a/apps/meseeks/skills/createAITool.ts b/apps/meseeks/skills/createAITool.ts deleted file mode 100644 index 5b2176be..00000000 --- a/apps/meseeks/skills/createAITool.ts +++ /dev/null @@ -1,343 +0,0 @@ -import { tool, type ModelMessage, type SystemModelMessage, type ToolSet } from 'ai'; -import type { z } from 'zod/v3'; -import { internal } from 'convex/_generated/api'; -import type { Doc, Id } from 'convex/_generated/dataModel'; -import type { ActionCtx, MutationCtx } from 'convex/_generated/server'; -import type { newActionSchema } from 'schemas/actionSchema'; -import type { skillSchema, softSkillSchema } from 'schemas/skillSchema'; -import { asDollars } from 'lib/money'; -import { stringToZod } from 'lib/zodToString'; -import { askMagicRock, type MagicRockContext } from 'convex/magicRock.private'; -import { env } from 'schemas/envSchema'; -import { DEFAULT_INTELLIGENCE, INTELLIGENCES, intelligenceKeys } from 'schemas/intelligenceSchema'; -import type { AITool, AIToolResult } from 'schemas/toolSchema'; - -export function createAITool( - ctx: ActionCtx | MutationCtx, - task: Doc<'tasks'>, - action: Doc<'actions'>, - skill: z.infer, - context?: MagicRockContext, -): AITool { - // - if (!context) { - // tool definition for LLM discovery only - // `execute` is required since AI SDK v6 - // _toolsForMagicRock sets execute = undefined on all tools, so this never runs - return tool({ - description: skill.description, - inputSchema: stringToZod(skill.inputSchema), - execute: async (): Promise => { - throw new Error(`Tool ${skill.key} executed without context`); - }, - }); - } - - return tool({ - description: skill.description, - inputSchema: stringToZod(skill.inputSchema), - execute: async (args) => { - // - console.debug('Running decision skill', skill.key, args); - - const { - text, // - toolCalls, - finishReason, - usage, - warnings, - providerMetadata, - // - } = await askMagicRock(context); - - console.debug('Provider metadata', providerMetadata); - - const reactions = [] as Array>; - - // oxfmt-ignore - const say = (text: string) => reactions.push({ - skillKey: 'say', - args: { message: text }, - taskId: task._id, - author: action._id, - owner: task.owner, - depth: action.depth + 1, - }); - - // get intelligence key for logging - const intelligenceKey = modelFrom(skill.config.model, task.preferredIntelligence); - - let reason = finishReason; - if (toolCalls.length > 0 && reason === 'stop') { - reason = 'tool-calls'; - console.info( - `(${intelligenceKey}) Has tool calls but finish reason is 'stop': ${toolCalls.map((call) => call.toolName).join(', ')}`, - ); - } - - switch (reason) { - // - case 'tool-calls': - // - if (toolCalls.length > 1) { - console.warn(`(${intelligenceKey}) Multiple tool calls`, toolCalls); - } - - if (toolCalls.length === 0) { - console.warn('No tool calls but finish reason is `tool-calls`'); - } - - // TODO: disabled multiple tool calls for now - we need to improve lifecyle first - // ...toolCalls.map((call) => ({ - // skillKey: call.toolName, - // args: call.input, - // taskId: task._id, - // author: action._id, - // owner: task.owner, - // depth: action.depth + 1, - // })), - - reactions.push({ - skillKey: toolCalls[0].toolName, - args: toolCalls[0].input, - taskId: task._id, - author: action._id, - owner: task.owner, - depth: action.depth + 1, - }); - break; - - // oxfmt-ignore - case 'stop': say(text); break; - - // oxfmt-ignore - case 'error': say(text); break; - - // oxfmt-ignore - case 'content-filter': say(`[damn @sama] Content filter hit: ${warnings}`); break; - - // TODO: better handling of max length - // oxfmt-ignore - case 'length': say(`Max length hit. ${warnings}`); break; - - // oxfmt-ignore - default: throw new Error(`Unknown finish reason: ${reason}`); - } - - if (warnings?.length) console.warn('Decision skill warnings', warnings); - - await _persistDetails({ - ctx, - action, - finishReason: reason, - text, - toolCalls, - usage, - warnings, - providerMetadata, - }); - - // warn if token counts are missing (could affect billing) - // they're optional since AI SDK v6 - if (usage.inputTokens === undefined || usage.outputTokens === undefined) { - console.warn( - `Missing token usage for ${skill.key}: input=${usage.inputTokens}, output=${usage.outputTokens}`, - ); - } - - return { - result: { - reactions, - }, - costs: [ - { - symbol: 'USD', - amount: calculateProviderCost({ - model: modelFrom(skill.config.model, task.preferredIntelligence), - inputTokens: { uncached: usage.inputTokens ?? 0 }, - outputTokens: { uncached: usage.outputTokens ?? 0 }, - }), - description: 'Provider cost', - }, - { - symbol: 'USD', - amount: env.ACTION_COST_USD, - description: 'Meseeks action (included on your plan)', - }, - ], - }; - }, - }); -} - -export function estimateCostFor( - skill: z.infer, // - task: Doc<'tasks'>, - actionId: Id<'actions'>, - context?: MagicRockContext, -) { - // - if (skill.cost !== 'dynamic') return skill.cost; - if (!context) throw new Error('Context is required for dynamic cost estimation'); - - const instructionsLength = extractSystemInstructions(context.system).length; - const toolsLength = computeToolsLength(context.tools); - const historyLength = computeHistoryLength(context.messages as Array); - - const inputLength = instructionsLength + toolsLength + historyLength; - - const inputTokens = Math.ceil(inputLength / env.CHAR_PER_TOKEN); - const outputTokens = Math.min(8000, Math.ceil(inputTokens / 4)); // 1/4 as input, capped at 8000, TODO: improve - - // assume worst-cast scenario with no cached tokens - const providerCost = calculateProviderCost({ - model: modelFrom(skill.config.model, task.preferredIntelligence), - inputTokens: { uncached: inputTokens }, - outputTokens: { uncached: outputTokens }, - }); - - const actionCost = env.ACTION_COST_USD; - const totalCost = providerCost + actionCost; - - // add a fixed margin to account for unpredictable costs and bad math - const marginPercent = env.COST_PREDICTION_MARGIN / 100; - const marginFactor = 100n + BigInt(Math.round(marginPercent * 100)); - const totalCostWithMargin = (totalCost * marginFactor) / 100n; - - console.debug( - `Estimated cost for ${skill.key} (${actionId}): ${asDollars({ bigInt: totalCostWithMargin, precision: 6 })} USD`, - ); - console.debug(`Input tokens: ${inputTokens}`); - console.debug(`Output tokens: ${outputTokens}`); - - return totalCostWithMargin; -} - -export function calculateProviderCost({ - model, // - inputTokens, - outputTokens, -}: { - model: z.infer; - inputTokens: { - uncached: number; - cached?: number; - }; - outputTokens: { - uncached: number; - cached?: number; - }; -}) { - // TODO: account for cached tokens - // inspect loggged providerMetadata to get the cached tokens path - const pricing = INTELLIGENCES[model].pricing; - - console.debug('Input tokens', inputTokens); - console.debug('Output tokens', outputTokens); - - const inputCost = BigInt(inputTokens.uncached) * pricing.inputPerToken; - const outputCost = BigInt(outputTokens.uncached) * pricing.outputPerToken; - const totalProviderCost = inputCost + outputCost; - - console.debug('Decision provider cost', asDollars({ bigInt: totalProviderCost, precision: 6 })); - console.debug('Action cost', asDollars({ bigInt: env.ACTION_COST_USD, precision: 6 })); - - return totalProviderCost; -} - -function computeToolsLength(toolSet?: ToolSet) { - // - if (!toolSet) return 0; - - let length = 0; - - // ToolSet is an object, so we need to iterate over its values - for (const key in toolSet) { - const tool = toolSet[key]; - length += tool.description?.length ?? 0; - // estimate schema contribution by serializing it - try { - length += JSON.stringify(tool.inputSchema).length; - } catch { - length += 100; // fallback if serialization fails - } - } - - return length; -} - -function computeHistoryLength(messages: Array) { - return messages.reduce((acc, message) => acc + message.content.length, 0); -} - -export function modelFrom( - skillModel: z.infer | 'auto', // - taskPreferredIntelligence?: z.infer, -): z.infer { - // - if (skillModel === 'auto') return taskPreferredIntelligence ?? DEFAULT_INTELLIGENCE; - - return skillModel; -} - -// Update existing action details with response data -async function _persistDetails({ - ctx, - action, - finishReason, - text, - toolCalls, - usage, - warnings, - providerMetadata, -}: { - ctx: ActionCtx | MutationCtx; - action: Doc<'actions'>; - finishReason?: string; - text?: string; - toolCalls: Array<{ toolName: string; input: Record }>; - usage: { inputTokens: number | undefined; outputTokens: number | undefined }; - warnings?: unknown[]; - providerMetadata?: Record; -}) { - // - await ctx.runMutation(internal.action.details._update, { - actionId: action._id, - updates: { - llm: { - finishReason, - text, - toolCalls, - usage: { - input: { - total: usage.inputTokens ?? 0, - cached: 0, // TODO: extract from providerMetadata if available - }, - output: { - total: usage.outputTokens ?? 0, - cached: 0, // TODO: extract from providerMetadata if available - }, - }, - warnings, - providerMetadata, - }, - }, - }); -} - -// extract system instructions as string from any format (string, SystemModelMessage, or array) -export function extractSystemInstructions( - system: string | SystemModelMessage | Array | undefined, -): string { - // - if (!system) return ''; - - if (typeof system === 'string') return system; - - if (Array.isArray(system)) { - return system.map((msg) => (typeof msg.content === 'string' ? msg.content : '')).join('\n'); - } - - // single SystemModelMessage - return typeof system.content === 'string' ? system.content : ''; -} diff --git a/apps/meseeks/skills/createBuiltInTool.ts b/apps/meseeks/skills/createBuiltInTool.ts deleted file mode 100644 index 50f46b6d..00000000 --- a/apps/meseeks/skills/createBuiltInTool.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { tool } from 'ai'; -import type { Doc } from 'convex/_generated/dataModel'; -import type { ActionCtx, MutationCtx } from 'convex/_generated/server'; -import type { AITool } from 'schemas/toolSchema'; -import type { z } from 'zod/v3'; -import { createReactions } from './createReactions'; -import type { Skill, ToolExecution } from './defineSkill'; - -export function createBuiltInTool( - ctx: ActionCtx | MutationCtx, - task: Doc<'tasks'>, - action: Doc<'actions'>, - skill: Skill, -): AITool { - // - const execution: ToolExecution = { ctx, task, action, skill }; - - return tool({ - description: skill.description, - inputSchema: skill.parameters, - execute: async (args) => { - // - const { text, reactions } = await skill.use(execution)(args); - - return { - result: { - ...(text ? { text } : {}), - reactions: createReactions(action, reactions), - }, - costs: [ - { - symbol: 'USD', - amount: 0n, - description: 'Built-in skills are free of charge.', - }, - ], - }; - }, - }); -} diff --git a/apps/meseeks/skills/createHttpTool.ts b/apps/meseeks/skills/createHttpTool.ts deleted file mode 100644 index f5c66b6e..00000000 --- a/apps/meseeks/skills/createHttpTool.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { tool } from 'ai'; -import { dset } from 'dset'; -import type { z } from 'zod/v3'; -import { internal } from 'convex/_generated/api'; -import type { Doc } from 'convex/_generated/dataModel'; -import type { ActionCtx, MutationCtx } from 'convex/_generated/server'; -import type { hardSkillSchema } from 'schemas/skillSchema'; -import { stringToZod } from 'lib/zodToString'; -import { env } from 'schemas/envSchema'; -import type { AITool } from 'schemas/toolSchema'; -import { createReactions } from './createReactions'; - -export function createHTTPTool( - ctx: ActionCtx | MutationCtx, - task: Doc<'tasks'>, - action: Doc<'actions'>, - skill: z.infer, -): AITool { - // - return tool({ - description: skill.description, - inputSchema: stringToZod(skill.inputSchema), - execute: async (args) => { - // - console.debug('Running skill', skill.key, args); - - const config = skill.config; - const url = new URL(config.url); - const headers = { ...config.headers }; - - // apply parameter mappings and compute the request body - const bodyData = config.paramMappings.reduce((body, { source, target, type }) => { - // - const value = args[source as keyof typeof args]; - - if (value) { - switch (type) { - case 'search': - url.searchParams.set(target, String(value)); - break; - case 'header': - headers[target] = String(value); - break; - case 'path': - url.pathname = url.pathname.replace(`:${target}`, String(value)); - break; - case 'body': - body[target] = value; - break; - case 'bodyPath': - dset(body, target, value); - break; - } - } - - return body; - // - }, config.body?.template ?? {}); - - const requestBody = Object.keys(bodyData).length > 0 ? JSON.stringify(bodyData) : undefined; - const requestBodySize = requestBody ? new Blob([requestBody]).size : undefined; - - console.debug('requesting', config.method, url.toString()); - - // make the request - const response = await fetch(url.toString(), { - method: config.method, - headers, - body: requestBody, - }); - - console.debug('Response', response.status, response.statusText); - - // treat everything as text and let the LLM do its magic - const text = await response.text(); - console.debug('Result', text); - - await _persistDetails({ - ctx, - action, - // skill, - // config, - // url, - requestBodySize, - response, - responseText: text, - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}. Body: ${text}`); - } - - return { - result: { - ...(text ? { text } : {}), - reactions: createReactions(action, skill.knownReactions), - }, - costs: [ - { - symbol: 'USD', - amount: skill.cost, - description: - skill.key === 'analyze' ? 'Free thanks to Daytona.io!' : 'Skill cost (to provider)', - }, - { - symbol: 'USD', - amount: env.ACTION_COST_USD, - description: 'Meseeks action (included on your plan)', - }, - ], - }; - }, - }); -} - -async function _persistDetails({ - ctx, - action, - // skill, - // config, - // url, - requestBodySize, - response, - responseText, -}: { - ctx: ActionCtx | MutationCtx; - action: Doc<'actions'>; - // skill: z.infer; - // config: z.infer['config']; - // url: URL; - requestBodySize?: number; - response: Response; - responseText?: string; -}) { - // - // capture response details for debugging - const responseBodySize = responseText ? new Blob([responseText]).size : undefined; - - // Store full response body but truncate based on env setting for safety within Convex's 1MB document limit - let responseBody: string | undefined; - - if (responseText) { - if (responseText.length <= env.MAX_HTTP_RESPONSE_BODY_BYTES) { - responseBody = responseText; - } else { - // Truncate and add a note about truncation - const truncated = responseText.substring(0, env.MAX_HTTP_RESPONSE_BODY_BYTES); - responseBody = `${truncated}\n\n[Response truncated at ${Math.round(env.MAX_HTTP_RESPONSE_BODY_BYTES / 1024)}KiB for Convex document size limit]`; - } - } - - // response headers are generally safe to persist (no API keys usually) - const responseHeaders: Record = {}; - response.headers.forEach((value, key) => { - responseHeaders[key] = value; - }); - - console.debug('Attempting to persist HTTP action details for action:', action._id); - - // Update existing action details with response data - await ctx.runMutation(internal.action.details._update, { - actionId: action._id, - updates: { - http: { - requestBodySize, - statusCode: response.status, - statusText: response.statusText, - responseBodySize, - responseBody, - responseHeaders, - }, - }, - }); -} diff --git a/apps/meseeks/skills/createReactions.ts b/apps/meseeks/skills/createReactions.ts deleted file mode 100644 index 9decedd9..00000000 --- a/apps/meseeks/skills/createReactions.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Doc, Id } from 'convex/_generated/dataModel'; - -export function createReactions( - action: Doc<'actions'>, - reactions?: Array<{ - skillKey: string; - args: Record; - condition?: 'owner' | 'companion' | 'any'; - }>, -) { - return (reactions ?? []) - .filter((reaction) => { - // oxfmt-ignore - switch (reaction.condition ?? 'any') { - case 'owner': return action.owner === action.author; - case 'companion': return action.owner !== action.author; - case 'any': return true; - default: return false; - } - }) - .map((reaction) => ({ - skillKey: reaction.skillKey, - args: reaction.args, - taskId: action.taskId, - owner: action.owner, - depth: action.depth + 1, - author: action._id as Id<'actions'> | Id<'users'>, // I have no idea why I need that cast, as it expects a union of Id<'actions'> or Id<'users'> - })); -} diff --git a/apps/meseeks/skills/defineSkill.ts b/apps/meseeks/skills/defineSkill.ts deleted file mode 100644 index 4849ba17..00000000 --- a/apps/meseeks/skills/defineSkill.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { z } from 'zod/v3'; -import { Doc } from 'convex/_generated/dataModel'; -import { ActionCtx, MutationCtx } from 'convex/_generated/server'; - -export type Skill = { - preApprovedCost: bigint | 'none'; - description: string; - parameters: T; - knownReactions: Array; - use: (execution: ToolExecution) => (args: z.infer) => Promise; - hidden?: boolean; - priority?: number; -}; - -export type ToolExecution = { - ctx: ActionCtx | MutationCtx; - task: Doc<'tasks'>; - action: Doc<'actions'>; - skill: Skill; -}; - -export const reactionSchema = z.object({ - skillKey: z.string(), - args: z.record(z.any()), - condition: z.enum(['owner', 'companion', 'any']).optional(), -}); -export type Reaction = z.infer; - -export const executionResultSchema = z.object({ - text: z.string().optional(), - reactions: z.array(reactionSchema), -}); -export type ExecutionResult = z.infer; - -// for the types -export const defineSkill = (skill: Skill) => ({ - ...skill, - hidden: skill.hidden ?? false, - priority: skill.priority ?? 999999999, -}); diff --git a/apps/meseeks/skills/tools.ts b/apps/meseeks/skills/tools.ts deleted file mode 100644 index c85c6799..00000000 --- a/apps/meseeks/skills/tools.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { z } from 'zod/v3'; -import type { Doc } from 'convex/_generated/dataModel'; -import type { ActionCtx, MutationCtx } from 'convex/_generated/server'; -import type { MagicRockContext } from 'convex/magicRock.private'; -import type { skillSchema } from 'schemas/skillSchema'; -import { _builtInSkills } from './builtIn/index'; -import { createAITool } from './createAITool'; -import { createBuiltInTool } from './createBuiltInTool'; -import { createHTTPTool } from './createHttpTool'; -import { internal } from 'convex/_generated/api'; - -export const _toolsForMagicRock = async ( - ctx: ActionCtx | MutationCtx, // - task: Doc<'tasks'>, - action: Doc<'actions'>, -) => { - // - const hardSkills = await ctx.runQuery(internal.skills._findAll, { - owner: task.owner, - kind: 'hard', - }); - - const softSkills = await ctx.runQuery(internal.skills._findAll, { - owner: task.owner, - kind: 'soft', - }); - - const map = { - ...toMap(hardSkills, (skill) => createTool(ctx, task, action, skill)), - ...toMap(softSkills, (skill) => createTool(ctx, task, action, skill)), - ..._builtInTools(ctx, task, action), - }; - - Object.values(map).forEach((skill) => { - // @ts-ignore TODO: workaround because I cannot stop AI SDK from calling execute() - skill.execute = undefined; - }); - - return map; -}; - -export const _builtInTools = ( - ctx: ActionCtx | MutationCtx, // - task: Doc<'tasks'>, - action: Doc<'actions'>, -) => { - // - - return Object.keys(_builtInSkills).reduce( - (acc, key) => { - // - const skill = _builtInSkills[key as keyof typeof _builtInSkills]; - - acc[key] = createBuiltInTool(ctx, task, action, skill); - - return acc; - }, - {} as Record>, - ); -}; - -export function createTool( - ctx: ActionCtx | MutationCtx, // - task: Doc<'tasks'>, - action: Doc<'actions'>, - skill: z.infer, - context?: MagicRockContext, -) { - // - // oxfmt-ignore - switch (skill.kind) { - case 'hard': return createHTTPTool(ctx, task, action, skill); - case 'soft': return createAITool(ctx, task, action, skill, context); - case 'built-in': { - // - if (skill.key in _builtInSkills) { - const builtInSkill = _builtInSkills[skill.key as keyof typeof _builtInSkills]; - return createBuiltInTool(ctx, task, action, builtInSkill); - } - - throw new Error(`Unknown built-in skill: ${skill.key}`); - } - } -} - -function toMap( - skills: Array, // - mapFn: (skill: SkillType) => ReturnType, -) { - return skills.reduce( - (acc, skill) => { - acc[skill.key] = mapFn(skill); - return acc; - }, - {} as Record, - ); -} diff --git a/apps/meseeks/src/client.tsx b/apps/meseeks/src/client.tsx index 0adc7379..03cc3028 100644 --- a/apps/meseeks/src/client.tsx +++ b/apps/meseeks/src/client.tsx @@ -13,7 +13,7 @@ Sentry.init({ Sentry.browserTracingIntegration(), // Sentry.replayIntegration(), ], - tracesSampleRate: 1.0, // Capture 100% of the transactions + tracesSampleRate: 1.0, // capture all performance traces. // Set 'tracePropagationTargets' to control for which URLs distributed tracing should be enabled tracePropagationTargets: [ /^https:\/\/meeseeks\.app/, diff --git a/apps/meseeks/src/components/Action.tsx b/apps/meseeks/src/components/Action.tsx deleted file mode 100644 index 76ccfb8b..00000000 --- a/apps/meseeks/src/components/Action.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Doc, Id } from 'convex/_generated/dataModel'; - -import index from './actions'; -import { GenericAction } from './actions/GenericAction'; - -export function Action(props: { - className?: string; - action: Doc<'actions'>; - initialRenderDate: Date; - isAuthorCurrentUser: boolean; - suppressAnchorId?: boolean; - taskId: Id<'tasks'>; -}) { - // - if (props.action.skillKey in index) { - // - const Component = index[props.action.skillKey as keyof typeof index]; - if (Component === null) return null; - - return ; - } - - return ; -} diff --git a/apps/meseeks/src/components/ActionComposer/ActionComposer.tsx b/apps/meseeks/src/components/ActionComposer/ActionComposer.tsx deleted file mode 100644 index 4a9b58d7..00000000 --- a/apps/meseeks/src/components/ActionComposer/ActionComposer.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import type { Doc } from 'convex/_generated/dataModel'; -import { useEffect, useMemo, useRef } from 'react'; -import { TooltipProvider } from '@reactor/ui/tooltip'; -import { useComposer } from '~/hooks/useComposer'; -import { useExpandingTextarea } from '@reactor/ui/hooks/useExpandingTextarea'; -import { useKeyboardShortcut } from '@reactor/ui/hooks/useKeyboardShortcuts'; -import { useStop } from '~/hooks/useTaskMutations'; -import { useVoiceRecording } from '~/hooks/useVoiceRecording'; -import { cn } from '@reactor/ui/lib/utils'; -import { IdleState } from './IdleState'; -import { RecordingState } from './RecordingState'; -import { TranscribingState } from './TranscribingState'; -import { StripContainer } from './strips/StripContainer'; - -interface ActionComposerProps { - // - task: Doc<'tasks'>; - onSubmit?: (message: string) => void; - className?: string; -} - -export function ActionComposer({ task, onSubmit, className }: ActionComposerProps) { - // - const { - queue, // - message, - enqueue, - setMessage, - submit, - } = useComposer(); - - const { stop, isStopping } = useStop(); - - const { - textareaRef, - value: localMessage, - isEmpty: isLocalEmpty, - onChange: handleLocalMessageChange, - setValue: setLocalMessage, - } = useExpandingTextarea({ singleLineHeight: 40 }); - - const intelligenceSelectorRef = useRef(null); - - // sync URL message to local textarea on mount and when URL changes externally - useEffect(() => { - if (message !== localMessage) { - setLocalMessage(message); - } - }, [message, localMessage, setLocalMessage]); - - // handle message change - update both local and URL state - const handleMessageChange = (e: React.ChangeEvent) => { - // - handleLocalMessageChange(e); - setMessage(e.target.value); - }; - - // sync local message back to URL when voice transcription completes - const handleTranscriptionComplete = (transcribedText: string) => { - // - setLocalMessage(transcribedText); - setMessage(transcribedText); - }; - - const { recordingStatus, startRecording, stopRecording, cancelRecording } = useVoiceRecording({ - onTranscriptionComplete: handleTranscriptionComplete, - }); - - const isRecordingOrTranscribing = recordingStatus !== 'idle'; - - // computed states for UI - const isComposing = !isLocalEmpty || queue.length > 0; - const isBlocked = useMemo(() => task.status === 'blocked' && isLocalEmpty, [task.status, isLocalEmpty]); - const isTaskActing = useMemo(() => task.status === 'acting' && isLocalEmpty, [task.status, isLocalEmpty]); - const canRequestIteration = useMemo( - () => isLocalEmpty && !isBlocked && !isTaskActing && queue.length === 0, - [isLocalEmpty, isBlocked, isTaskActing, queue.length], - ); - - const handleAct = async () => { - // - await submit(task); - - if (!isLocalEmpty) { - onSubmit?.(localMessage); - } - }; - - const handleEnqueueMessage = () => { - // - const trimmed = localMessage.trim(); - if (!trimmed) return; - - // enqueue with clearMessage option to avoid race condition - const didEnqueue = enqueue( - { - skillKey: 'say', - args: { message: trimmed }, - source: 'input', - }, - { clearMessage: true }, - ); - - // only clear local state if enqueue succeeded - if (didEnqueue) { - setLocalMessage(''); - } - }; - - const handleStop = () => { - // - stop({ taskId: task._id }); - }; - - // global focus shortcut (⌘+I) - useKeyboardShortcut({ - global: true, - combo: { withCommand: true, key: 'i' }, - callback: () => { - textareaRef.current?.focus(); - const length = textareaRef.current?.value.length || 0; - textareaRef.current?.setSelectionRange(length, length); - }, - }); - - // submit/request iteration shortcut (⌘+Enter) - useKeyboardShortcut({ - targetRef: textareaRef, - combo: { withCommand: true, key: 'Enter' }, - callback: () => { - if (recordingStatus === 'idle' && !isStopping) { - handleAct(); - } - }, - }); - - // enqueue message shortcut (⌥+Enter) - useKeyboardShortcut({ - targetRef: textareaRef, - combo: { withAlt: true, key: 'Enter' }, - callback: () => { - if (recordingStatus === 'idle') { - handleEnqueueMessage(); - } - }, - }); - - // stop acting shortcut (CTRL+C) - useKeyboardShortcut({ - global: true, - combo: { withCtrl: true, key: 'c' }, - skipPreventDefault: true, - callback: (e) => { - if (task.status === 'acting' && !isStopping) { - handleStop(); - e.preventDefault(); - } - }, - }); - - // intelligence selector shortcut (⌘+/) - useKeyboardShortcut({ - global: true, - combo: { withCommand: true, key: '/' }, - callback: () => intelligenceSelectorRef.current?.click(), - }); - - return ( - -
- {/* strips - only visible when idle */} - {!isRecordingOrTranscribing && ( -
- -
- )} - - {/* recording state */} - {recordingStatus === 'recording' && ( - - )} - - {/* transcribing state */} - {recordingStatus === 'transcribing' && } - - {/* idle state - input and action bar */} - {!isRecordingOrTranscribing && ( - 0} - startRecording={startRecording} - handleAct={handleAct} - handleEnqueue={handleEnqueueMessage} - isActing={isTaskActing} - isBlocked={isBlocked} - isComposing={isComposing} - canRequestIteration={canRequestIteration} - intelligenceSelectorRef={intelligenceSelectorRef} - handleStop={handleStop} - /> - )} -
-
- ); -} diff --git a/apps/meseeks/src/components/ActionComposer/IdleState.tsx b/apps/meseeks/src/components/ActionComposer/IdleState.tsx deleted file mode 100644 index 619284ff..00000000 --- a/apps/meseeks/src/components/ActionComposer/IdleState.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import type { Doc } from 'convex/_generated/dataModel'; -import { intelligenceKeys } from 'schemas/intelligenceSchema'; -import { ArrowUp, Hourglass, Mic, Sparkles, Square } from 'lucide-react'; -import { useEffect, useState } from 'react'; -import { z } from 'zod/v3'; -import { IntelligenceSelector } from '~/components/IntelligenceSelector'; -import { SkillsLink } from '~/components/SkillsLink'; -import { ActionButton } from '@reactor/ui/action-button'; -import { useSetPreferredIntelligence } from '~/hooks/useTaskMutations'; -import { KeyboardShortcutIndicator } from './KeyboardShortcutIndicator'; - -interface IdleStateProps { - // - task: Doc<'tasks'>; - textareaRef: React.RefObject; - message: string; - handleMessageChange: (e: React.ChangeEvent) => void; - isEmpty: boolean; - hasQueuedSkills: boolean; - startRecording: () => void; - handleAct: () => void; - handleEnqueue: () => void; - isBlocked: boolean; - isActing: boolean; - isComposing: boolean; - canRequestIteration: boolean; - intelligenceSelectorRef: React.RefObject; - handleStop: () => void; -} - -export function IdleState({ - task, - textareaRef, - message, - handleMessageChange, - isEmpty, - hasQueuedSkills, - startRecording, - handleAct, - handleEnqueue, - isBlocked, - isActing, - isComposing, - canRequestIteration, - intelligenceSelectorRef, - handleStop, -}: IdleStateProps) { - // - const { setPreferredIntelligence, isSettingPreferredIntelligence } = useSetPreferredIntelligence(); - const handleIntelligenceChange = (key: z.infer) => { - if (isSettingPreferredIntelligence) return; - setPreferredIntelligence({ taskId: task._id, preferredIntelligence: key }); - }; - - return ( - <> -
-