diff --git a/apps/meseeks/convex.json b/apps/meseeks/convex.json index 8a737888..add49086 100644 --- a/apps/meseeks/convex.json +++ b/apps/meseeks/convex.json @@ -5,7 +5,14 @@ "enabled": false }, "node": { - "externalPackages": ["@babel/core", "@babel/preset-react"], + "externalPackages": [ + "@babel/core", + "@babel/preset-react", + "@daytona/sdk", + "@jitl/quickjs-singlefile-cjs-release-sync", + "quickjs-emscripten-core", + "tslib" + ], "nodeVersion": "24" } } diff --git a/apps/meseeks/convex/_generated/api.d.ts b/apps/meseeks/convex/_generated/api.d.ts index 94b83bb3..c549efd6 100644 --- a/apps/meseeks/convex/_generated/api.d.ts +++ b/apps/meseeks/convex/_generated/api.d.ts @@ -8,24 +8,28 @@ * @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 fileContent from "../fileContent.js"; +import type * as files from "../files.js"; import type * as http from "../http.js"; +import type * as loops from "../loops.js"; import type * as magicRock from "../magicRock.js"; import type * as migrations from "../migrations.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 reads from "../reads.js"; +import type * as routes from "../routes.js"; +import type * as runtime from "../runtime.js"; +import type * as runtimeState from "../runtimeState.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 triggerIsolate from "../triggerIsolate.js"; +import type * as triggerIsolateState from "../triggerIsolateState.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"; @@ -38,24 +42,28 @@ 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; + fileContent: typeof fileContent; + files: typeof files; http: typeof http; + loops: typeof loops; magicRock: typeof magicRock; migrations: typeof migrations; - reactor: typeof reactor; - "schedule/lifecycle": typeof schedule_lifecycle; - schedules: typeof schedules; + reads: typeof reads; + routes: typeof routes; + runtime: typeof runtime; + runtimeState: typeof runtimeState; seed: typeof seed; skills: typeof skills; subscriptions: typeof subscriptions; - tasks: typeof tasks; topUps: typeof topUps; transactions: typeof transactions; + triggerIsolate: typeof triggerIsolate; + triggerIsolateState: typeof triggerIsolateState; + triggers: typeof triggers; users: typeof users; "users/preferences": typeof users_preferences; "users/requests": typeof users_requests; 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 deleted file mode 100644 index 6fad5d66..00000000 --- a/apps/meseeks/convex/action/details.private.ts +++ /dev/null @@ -1,56 +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 { actionDetailSchema, actionDetailUpdateSchema } from 'schemas/actionDetailSchema'; - -export const findActionDetails = defineQuery({ - args: z.object({ - actionId: zid('actions'), - }), - handler: async (ctx, { actionId }) => { - // - return await ctx.db - .query('action_details') - .withIndex('by_action', (q) => q.eq('actionId', actionId)) - .unique(); - }, -}); - -export const persistActionDetails = defineMutation({ - args: z.object({ - details: actionDetailSchema, - }), - handler: async (ctx, { details }) => { - // - 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); - }, -}); - -export const updateActionDetails = defineMutation({ - args: z.object({ - actionId: zid('actions'), - updates: actionDetailUpdateSchema, - }), - handler: async (ctx, { actionId, updates }) => { - // - const existing = await findActionDetails(ctx, { actionId }); - if (!existing) throw NotFound(); - - // 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 ('http' in updates && 'http' in existing) { - const updatedHttp = { ...existing.http, ...updates.http }; - return await ctx.db.patch(existing._id, { http: updatedHttp }); - } - - throw new Error('Update type does not match existing document type'); - }, -}); diff --git a/apps/meseeks/convex/action/details.ts b/apps/meseeks/convex/action/details.ts deleted file mode 100644 index 4d06966e..00000000 --- a/apps/meseeks/convex/action/details.ts +++ /dev/null @@ -1,34 +0,0 @@ -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 { 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, -}); - -export const find = query({ - args: { - actionId: zid('actions'), - }, - handler: async (ctx, { actionId }) => { - // - const currentUser = await getCurrentUser(ctx, {}); - - // get the action to verify ownership - const action = await ctx.db.get(actionId); - if (!action) throw NotFound(); - if (action.owner !== currentUser._id) throw NotFound(); - - return await findActionDetails(ctx, { actionId }); - }, -}); diff --git a/apps/meseeks/convex/actions.ts b/apps/meseeks/convex/actions.ts new file mode 100644 index 00000000..5f1779dc --- /dev/null +++ b/apps/meseeks/convex/actions.ts @@ -0,0 +1,761 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import { NotFound } from 'lib/errors'; +import { mutation, query } from 'lib/convex'; +import { actionResultSchema, costSchema } from 'schemas/actionSchema'; +import { paginationOptionsSchema } from 'schemas/paginationOptionsSchema'; +import { internal } from './_generated/api'; +import type { Id } from './_generated/dataModel'; +import type { MutationCtx } from './_generated/server'; +import { + adjustFileBudget, + clearFileTag, + createFile, + ensureFileOwner, + moveFile, + releaseAvailableFileBudget, + setFileTags, + writeFileContent, +} from './files.private'; +import { createLoop, resolveLoop } from './loops.private'; +import { + claimAction, + enqueueHumanAction, + recordHumanAction, + recordMutationAction, + interruptFileWork, + recentActionsForFile, + settleAction, +} from './reactor.private'; +import { upsertRoute } from './routes.private'; +import { TRIGGER_MAX_USES_UNLIMITED, upsertFileTrigger, upsertLoopTrigger } from './triggers.private'; +import { getCurrentUser } from './users.private'; + +const enqueuedSkillSchema = z.object({ + skillKey: z.string().min(1), + args: z.record(z.unknown()).default({}), + source: z.string().optional(), +}); +const tagUpdatesSchema = z.array( + z.object({ + key: z.string().min(1), + value: z.string(), + }), +); +const createFileSkillArgsSchema = z.object({ + parent: zid('files').optional(), + name: z.string().min(1), + content: z.string().optional(), + tags: tagUpdatesSchema.default([]), + shouldAddInboxTag: z.boolean().default(true), +}); +const loopVisualSkillArgsSchema = z + .object({ + icon: z.string().min(1), + color: z.string().min(1), + tint: z.string().min(1), + }) + .default({ + icon: 'circle', + color: 'zinc', + tint: 'zinc', + }); +const createLoopSkillArgsSchema = z.object({ + key: z.string().min(1), + name: z.string().min(1), + description: z.string().default(''), + defaultIntelligenceKey: z.string().min(1).optional(), + visual: loopVisualSkillArgsSchema, +}); +const createTriggerSkillArgsSchema = z.union([ + z.object({ + kind: z.literal('file'), + file: zid('files').optional(), + handler: zid('files'), + maxUses: z.number().int().nonnegative().default(TRIGGER_MAX_USES_UNLIMITED), + }), + z.object({ + kind: z.literal('loop'), + loop: zid('loops'), + handler: zid('files'), + maxUses: z.number().int().nonnegative().default(TRIGGER_MAX_USES_UNLIMITED), + }), +]); +const createRouteSkillArgsSchema = z.object({ + slug: z.string().min(1), + file: zid('files'), + defaultFile: zid('files').optional(), +}); +export const act = mutation({ + args: { + fileId: zid('files'), + skills: z.array(enqueuedSkillSchema).min(1), + loopKey: z.string().min(1).nullable().optional(), + intelligence: z.string().min(1).optional(), + shouldReopen: z.boolean().optional(), + }, + handler: async (ctx, { fileId, skills, loopKey, intelligence, shouldReopen }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + await ensureFileOwner(ctx, { fileId: fileId, owner: currentUser._id }); + + return await createActionsForFile(ctx, { + owner: currentUser._id, + file: fileId, + skills, + loopKey: loopKey ?? undefined, + intelligenceKey: intelligence, + shouldReopen, + }); + }, +}); + +export const claim = mutation({ + args: { + actionId: zid('actions'), + expectedCost: z.bigint(), + maxCost: z.bigint(), + }, + handler: async (ctx, args) => { + // + const currentUser = await getCurrentUser(ctx, {}); + const action = await ctx.db.get(args.actionId); + if (!action || action.owner !== currentUser._id) throw NotFound(); + await ensureFileOwner(ctx, { + fileId: action.file, + owner: currentUser._id, + }); + + return await claimAction(ctx, args); + }, +}); + +export const settle = mutation({ + args: { + actionId: zid('actions'), + status: z.enum(['succeeded', 'failed', 'skipped']), + result: actionResultSchema.optional(), + costs: z.array(costSchema).default([]), + }, + handler: async (ctx, args) => { + // + const currentUser = await getCurrentUser(ctx, {}); + const action = await ctx.db.get(args.actionId); + if (!action || action.owner !== currentUser._id) throw NotFound(); + await ensureFileOwner(ctx, { + fileId: action.file, + owner: currentUser._id, + }); + + const settled = await settleAction(ctx, args); + await evaluateReactionTriggers(ctx, { actionId: args.actionId }); + + return settled; + }, +}); + +export const recent = query({ + args: { + file: zid('files'), + limit: z.number().int().positive().max(100).default(25), + }, + handler: async (ctx, { file, limit }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + await ensureFileOwner(ctx, { fileId: file, owner: currentUser._id }); + + return await recentActionsForFile(ctx, { file, limit }); + }, +}); + +export const findAllPaginated = query({ + args: { + fileId: zid('files'), + paginationOpts: paginationOptionsSchema, + }, + handler: async (ctx, { fileId, paginationOpts }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + await ensureFileOwner(ctx, { fileId: fileId, owner: currentUser._id }); + + return await ctx.db + .query('actions') + .withIndex('by_file_index', (q) => q.eq('file', fileId)) + .order('desc') + .paginate(paginationOpts); + }, +}); + +export const findDetails = query({ + args: { + actionId: zid('actions'), + }, + handler: async (ctx, { actionId }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + const action = await ctx.db.get(actionId); + if (!action || action.owner !== currentUser._id) return undefined; + + const details = await ctx.db + .query('details') + .withIndex('by_action', (q) => q.eq('action', actionId)) + .unique(); + + return { + action, + details, + }; + }, +}); + +export const authorize = mutation({ + args: { + fileId: zid('files'), + actionId: zid('actions'), + hasApproved: z.boolean(), + }, + handler: async (ctx, { fileId, actionId, hasApproved }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + await ensureFileOwner(ctx, { fileId: fileId, owner: currentUser._id }); + const action = await ctx.db.get(actionId); + if (!action || action.owner !== currentUser._id || action.file !== fileId) throw NotFound(); + + if (!hasApproved) { + await ctx.db.patch(actionId, { + status: 'skipped', + settledAt: Date.now(), + }); + return; + } + + await ctx.db.patch(actionId, { + status: 'enqueued', + authorizedAt: Date.now(), + }); + await ctx.scheduler.runAfter(0, internal.runtime._perform, { + owner: currentUser._id, + actionId, + }); + }, +}); + +export async function createActionsForFile( + ctx: MutationCtx, + args: { + owner: Id<'users'>; + file: Id<'files'>; + skills: Array>; + loopKey?: string; + intelligenceKey?: string; + shouldReopen?: boolean; + }, +) { + // + if (args.shouldReopen) { + await setFileTags(ctx, { + owner: args.owner, + file: args.file, + author: args.owner, + tags: [ + { key: 'kind', value: 'task' }, + { key: 'status', value: 'active' }, + ], + actionSkill: 'updateFileMetadata', + }); + } + + const actionIds: Id<'actions'>[] = []; + + for (const skill of args.skills) { + const actionId = await createActionForSkillRequest(ctx, { + owner: args.owner, + file: args.file, + skill, + loopKey: args.loopKey, + intelligenceKey: args.intelligenceKey, + }); + if (actionId) actionIds.push(actionId); + } + + return actionIds; +} + +async function createActionForSkillRequest( + ctx: MutationCtx, + args: { + owner: Id<'users'>; + file: Id<'files'>; + skill: z.infer; + loopKey?: string; + intelligenceKey?: string; + }, +) { + // + if (args.skill.skillKey === 'say') { + const text = stringArg(args.skill.args, 'message') ?? stringArg(args.skill.args, 'text') ?? ''; + if (!text.trim()) return undefined; + + return await createSayAction(ctx, { + owner: args.owner, + file: args.file, + author: args.owner, + text, + loopKey: args.loopKey, + intelligenceKey: args.intelligenceKey, + shouldEvaluateTriggers: true, + }); + } + + if (args.skill.skillKey === 'createFile') { + const parsed = createFileSkillArgsSchema.safeParse(args.skill.args); + if (!parsed.success) { + return await recordFailedSkillAction(ctx, { + owner: args.owner, + file: args.file, + author: args.owner, + skillKey: args.skill.skillKey, + actionArgs: args.skill.args, + message: 'createFile arguments are invalid.', + }); + } + + const fileId = await createFile(ctx, { + owner: args.owner, + parent: parsed.data.parent ?? args.file, + name: parsed.data.name, + author: args.owner, + content: parsed.data.content, + tags: parsed.data.tags, + shouldAddInboxTag: parsed.data.shouldAddInboxTag, + shouldCreateAction: false, + }); + + return await recordMutationAction(ctx, { + owner: args.owner, + file: args.file, + author: args.owner, + skillKey: 'createFile', + args: args.skill.args, + result: { + text: `Created ${parsed.data.name}.`, + files: [ + { + file: fileId, + path: parsed.data.name, + }, + ], + }, + }); + } + + if (args.skill.skillKey === 'updateBudget') { + const amount = bigintArg(args.skill.args, 'amount') ?? 0n; + await adjustFileBudget(ctx, { + owner: args.owner, + file: args.file, + author: args.owner, + amount, + }); + return undefined; + } + + if (args.skill.skillKey === 'createLoop') { + const parsed = createLoopSkillArgsSchema.safeParse(args.skill.args); + if (!parsed.success) { + return await recordFailedSkillAction(ctx, { + owner: args.owner, + file: args.file, + author: args.owner, + skillKey: args.skill.skillKey, + actionArgs: args.skill.args, + message: 'createLoop arguments are invalid.', + }); + } + + const created = await createLoop(ctx, { + owner: args.owner, + author: args.owner, + auditFile: args.file, + ...parsed.data, + }); + return created.actionId; + } + + if (args.skill.skillKey === 'createTrigger') { + const parsed = createTriggerSkillArgsSchema.safeParse(args.skill.args); + if (!parsed.success) { + return await recordFailedSkillAction(ctx, { + owner: args.owner, + file: args.file, + author: args.owner, + skillKey: args.skill.skillKey, + actionArgs: args.skill.args, + message: 'createTrigger arguments are invalid.', + }); + } + + if (parsed.data.kind === 'file') { + await upsertFileTrigger(ctx, { + owner: args.owner, + author: args.owner, + file: parsed.data.file ?? args.file, + handler: parsed.data.handler, + maxUses: parsed.data.maxUses, + auditFile: args.file, + }); + return undefined; + } + + await upsertLoopTrigger(ctx, { + owner: args.owner, + author: args.owner, + loop: parsed.data.loop, + handler: parsed.data.handler, + maxUses: parsed.data.maxUses, + auditFile: args.file, + }); + return undefined; + } + + if (args.skill.skillKey === 'createRoute') { + const parsed = createRouteSkillArgsSchema.safeParse(args.skill.args); + if (!parsed.success) { + return await recordFailedSkillAction(ctx, { + owner: args.owner, + file: args.file, + author: args.owner, + skillKey: args.skill.skillKey, + actionArgs: args.skill.args, + message: 'createRoute arguments are invalid.', + }); + } + + await upsertRoute(ctx, { + owner: args.owner, + author: args.owner, + slug: parsed.data.slug, + file: parsed.data.file, + defaultFile: parsed.data.defaultFile, + }); + return undefined; + } + + if (args.skill.skillKey === 'updateFileContent') { + const content = stringArg(args.skill.args, 'content'); + if (content === undefined) return undefined; + + const summary = await writeFileContent(ctx, { + fileId: args.file, + owner: args.owner, + author: args.owner, + content, + shouldCreateAction: false, + }); + await recordMutationAction(ctx, { + owner: args.owner, + file: args.file, + author: args.owner, + skillKey: 'updateFileContent', + args: args.skill.args, + result: { + text: summary, + files: [ + { + file: args.file, + path: 'index.md', + }, + ], + }, + }); + return undefined; + } + + if (args.skill.skillKey === 'rename' || args.skill.skillKey === 'move') { + const name = stringArg(args.skill.args, 'name'); + const parsedParent = zid('files').safeParse(args.skill.args['parent']); + if (!name && !parsedParent.success) return undefined; + + const summary = await moveFile(ctx, { + fileId: args.file, + owner: args.owner, + author: args.owner, + newParent: parsedParent.success ? parsedParent.data : undefined, + newName: name ? name.slice(0, 60).trim() : undefined, + shouldCreateAction: false, + }); + await recordMutationAction(ctx, { + owner: args.owner, + file: args.file, + author: args.owner, + skillKey: 'move', + args: args.skill.args, + result: { + text: summary, + files: [ + { + file: args.file, + path: summary, + }, + ], + }, + }); + return undefined; + } + + if (args.skill.skillKey === 'tag') { + const key = stringArg(args.skill.args, 'key'); + if (!key) return undefined; + + const value = stringArg(args.skill.args, 'value'); + let summary: string; + if (value === undefined) { + summary = await clearFileTag(ctx, { + owner: args.owner, + file: args.file, + author: args.owner, + key, + actionSkill: 'tag', + shouldCreateAction: false, + }); + } else { + summary = await setFileTags(ctx, { + owner: args.owner, + file: args.file, + author: args.owner, + tags: [{ key, value }], + actionSkill: 'tag', + shouldCreateAction: false, + }); + } + if (!summary) return undefined; + + await recordMutationAction(ctx, { + owner: args.owner, + file: args.file, + author: args.owner, + skillKey: 'tag', + args: args.skill.args, + result: { + text: summary, + files: [], + }, + }); + return undefined; + } + + if (args.skill.skillKey === 'updateFileMetadata') { + const name = stringArg(args.skill.args, 'name'); + const parsedTags = tagUpdatesSchema.safeParse(args.skill.args['tags']); + const summaries = []; + + if (name) { + const summary = await moveFile(ctx, { + fileId: args.file, + owner: args.owner, + author: args.owner, + newName: name.slice(0, 60).trim(), + shouldCreateAction: false, + }); + summaries.push(summary); + } + if (parsedTags.success && parsedTags.data.length > 0) { + const summary = await setFileTags(ctx, { + owner: args.owner, + file: args.file, + author: args.owner, + tags: parsedTags.data, + actionSkill: 'updateFileMetadata', + shouldCreateAction: false, + }); + if (summary) summaries.push(summary); + + if (shouldReleaseBudgetForTags(parsedTags.data)) { + const budgetSummary = await releaseAvailableFileBudget(ctx, { + owner: args.owner, + file: args.file, + author: args.owner, + actionSkill: 'updateFileMetadata', + shouldCreateAction: false, + }); + if (budgetSummary) summaries.push(budgetSummary); + } + } + if (summaries.length === 0) return undefined; + + await recordMutationAction(ctx, { + owner: args.owner, + file: args.file, + author: args.owner, + skillKey: 'updateFileMetadata', + args: args.skill.args, + result: { + text: summaries.join('\n'), + files: [], + }, + }); + return undefined; + } + + if (args.skill.skillKey === 'interrupt') { + await interruptFileWork(ctx, { file: args.file, interruptedAt: Date.now() }); + return await recordMutationAction(ctx, { + owner: args.owner, + file: args.file, + author: args.owner, + skillKey: 'interrupt', + args: {}, + result: { + text: 'Interrupted older work.', + files: [], + }, + }); + } + + return await enqueueRuntimeAction(ctx, { + owner: args.owner, + file: args.file, + author: args.owner, + skillKey: args.skill.skillKey, + actionArgs: args.skill.args, + loopKey: args.loopKey, + intelligenceKey: args.intelligenceKey, + }); +} + +async function createSayAction( + ctx: MutationCtx, + args: { + owner: Id<'users'>; + file: Id<'files'>; + author: Id<'users'>; + text: string; + loopKey?: string; + intelligenceKey?: string; + shouldEvaluateTriggers: boolean; + }, +) { + // + await interruptFileWork(ctx, { file: args.file, interruptedAt: Date.now() }); + + const resolvedLoop = args.loopKey + ? await resolveLoop(ctx, { owner: args.owner, loopKey: args.loopKey }) + : undefined; + const actionArgs: Record = { text: args.text, message: args.text }; + const actionId = await recordHumanAction(ctx, { + owner: args.owner, + file: args.file, + author: args.author, + skillKey: 'say', + loopKey: resolvedLoop?.key, + intelligenceKey: args.intelligenceKey, + args: actionArgs, + result: { + text: args.text, + files: [], + }, + }); + + if (args.shouldEvaluateTriggers) await evaluateReactionTriggers(ctx, { actionId }); + + return actionId; +} + +async function evaluateReactionTriggers( + ctx: MutationCtx, + args: { + actionId: Id<'actions'>; + }, +) { + // + const action = await ctx.db.get(args.actionId); + if (!action || action.status !== 'succeeded') return; + if (action.interruptedAt !== undefined) return; + + await ctx.scheduler.runAfter(0, internal.triggerIsolate._evaluate, { + owner: action.owner, + actionId: args.actionId, + }); +} + +async function enqueueRuntimeAction( + ctx: MutationCtx, + args: { + owner: Id<'users'>; + file: Id<'files'>; + author: Id<'users'>; + skillKey: string; + actionArgs: Record; + loopKey?: string; + intelligenceKey?: string; + }, +) { + // + const resolvedLoop = args.loopKey + ? await resolveLoop(ctx, { owner: args.owner, loopKey: args.loopKey }) + : undefined; + const actionId = await enqueueHumanAction(ctx, { + owner: args.owner, + file: args.file, + author: args.author, + skillKey: args.skillKey, + args: args.actionArgs, + loopKey: resolvedLoop?.key, + intelligenceKey: args.intelligenceKey, + }); + + await ctx.scheduler.runAfter(0, internal.runtime._perform, { + owner: args.owner, + actionId, + }); + + return actionId; +} + +function stringArg(args: Record, key: string) { + // + const value = args[key]; + return typeof value === 'string' ? value : undefined; +} + +function bigintArg(args: Record, key: string) { + // + const value = args[key]; + return typeof value === 'bigint' ? value : undefined; +} + +async function recordFailedSkillAction( + ctx: MutationCtx, + args: { + owner: Id<'users'>; + file: Id<'files'>; + author: Id<'users'>; + skillKey: string; + actionArgs: Record; + message: string; + }, +) { + // + return await recordHumanAction(ctx, { + owner: args.owner, + file: args.file, + author: args.author, + skillKey: args.skillKey, + args: args.actionArgs, + status: 'failed', + result: { + text: args.message, + files: [], + }, + }); +} + +function shouldReleaseBudgetForTags(tags: z.infer) { + // + for (const tag of tags) { + if (tag.key !== 'status') continue; + if (tag.value === 'done' || tag.value === 'discarded') return true; + } + + return false; +} diff --git a/apps/meseeks/convex/babel.ts b/apps/meseeks/convex/babel.ts index 5e6a8296..e52b5935 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 render flows that need jsx transpiled before sandbox iframe execution export const _transpileCode = internalAction({ args: { code: z.string(), 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 index dfcff100..e419e9f8 100644 --- a/apps/meseeks/convex/drafts.private.ts +++ b/apps/meseeks/convex/drafts.private.ts @@ -6,59 +6,46 @@ import { draftQueueItemSchema } from 'schemas/draftSchema'; export const findDraft = defineQuery({ args: z.object({ owner: zid('users'), - taskId: zid('tasks'), + fileId: zid('files'), }), - handler: async (ctx, { owner, taskId }) => { + handler: async (ctx, { owner, fileId }) => { // return await ctx.db .query('drafts') - .withIndex('by_owner_taskId', (q) => q.eq('owner', owner).eq('taskId', taskId)) + .withIndex('by_owner_fileId', (q) => q.eq('owner', owner).eq('fileId', fileId)) .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'), + fileId: zid('files'), queue: z.array(draftQueueItemSchema), message: z.string(), }), - handler: async (ctx, { owner, taskId, queue, message }) => { + handler: async (ctx, { owner, fileId, queue, message }) => { // - const existing = await findDraft(ctx, { owner, taskId }); - const data = { owner, taskId, queue, message, updatedAt: Date.now() }; + const existing = await findDraft(ctx, { owner, fileId }); + const data = { owner, fileId, queue, message, updatedAt: Date.now() }; if (existing) { await ctx.db.patch(existing._id, data); - } else { - await ctx.db.insert('drafts', data); + return existing._id; } + + return await ctx.db.insert('drafts', data); }, }); export const clearDraft = defineMutation({ args: z.object({ owner: zid('users'), - taskId: zid('tasks'), + fileId: zid('files'), }), - handler: async (ctx, { owner, taskId }) => { + handler: async (ctx, { owner, fileId }) => { // - const existing = await findDraft(ctx, { owner, taskId }); + const existing = await findDraft(ctx, { owner, fileId }); if (existing) await ctx.db.delete(existing._id); }, diff --git a/apps/meseeks/convex/drafts.ts b/apps/meseeks/convex/drafts.ts index a707a720..e5531b1e 100644 --- a/apps/meseeks/convex/drafts.ts +++ b/apps/meseeks/convex/drafts.ts @@ -1,41 +1,45 @@ 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 { clearDraft, findDraft, saveDraft } from './drafts.private'; +import { ensureFileOwner } from './files.private'; import { getCurrentUser } from './users.private'; export const findOne = query({ args: { - taskId: zid('tasks'), + fileId: zid('files'), }, - handler: async (ctx, { taskId }) => { + handler: async (ctx, { fileId }) => { // const currentUser = await getCurrentUser(ctx, {}); - return await findDraft(ctx, { owner: currentUser._id, taskId }); + await ensureFileOwner(ctx, { fileId: fileId, owner: currentUser._id }); + return await findDraft(ctx, { owner: currentUser._id, fileId: fileId }); }, }); export const save = mutation({ args: { - taskId: zid('tasks'), + fileId: zid('files'), queue: z.array(draftQueueItemSchema), message: z.string(), }, - handler: async (ctx, { taskId, queue, message }) => { + handler: async (ctx, { fileId, queue, message }) => { // const currentUser = await getCurrentUser(ctx, {}); - return await saveDraft(ctx, { owner: currentUser._id, taskId, queue, message }); + await ensureFileOwner(ctx, { fileId: fileId, owner: currentUser._id }); + return await saveDraft(ctx, { owner: currentUser._id, fileId: fileId, queue, message }); }, }); export const clear = mutation({ args: { - taskId: zid('tasks'), + fileId: zid('files'), }, - handler: async (ctx, { taskId }) => { + handler: async (ctx, { fileId }) => { // const currentUser = await getCurrentUser(ctx, {}); - await clearDraft(ctx, { owner: currentUser._id, taskId }); + await ensureFileOwner(ctx, { fileId: fileId, owner: currentUser._id }); + await clearDraft(ctx, { owner: currentUser._id, fileId: fileId }); }, }); diff --git a/apps/meseeks/convex/fileContent.ts b/apps/meseeks/convex/fileContent.ts new file mode 100644 index 00000000..49c7c552 --- /dev/null +++ b/apps/meseeks/convex/fileContent.ts @@ -0,0 +1,47 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { internalMutation, internalQuery } from 'lib/convex'; +import { authorSchema } from 'schemas/authorSchema'; +import { objectContentPointerSchema } from 'schemas/fileSchema'; +import { catFile, ensureFileOwner, setObjectContentPointer } from './files.private'; + +export const _readContext = internalQuery({ + args: { + owner: zid('users'), + fileId: zid('files'), + }, + handler: async (ctx, { owner, fileId }) => { + // + const file = await ensureFileOwner(ctx, { fileId, owner }); + if (!file.currentContent) { + return { + source: 'empty', + text: '', + }; + } + + if (file.currentContent.kind === 'text') { + return { + source: 'text', + text: await catFile(ctx, { fileId, owner }), + }; + } + + return { + source: 'object', + object: file.currentContent, + }; + }, +}); + +export const _setObjectPointer = internalMutation({ + args: { + owner: zid('users'), + fileId: zid('files'), + author: authorSchema, + pointer: objectContentPointerSchema, + }, + handler: async (ctx, args) => { + // + await setObjectContentPointer(ctx, args); + }, +}); diff --git a/apps/meseeks/convex/files.private.ts b/apps/meseeks/convex/files.private.ts new file mode 100644 index 00000000..937368e6 --- /dev/null +++ b/apps/meseeks/convex/files.private.ts @@ -0,0 +1,1145 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import { defineMutation, defineQuery } from 'lib/convex'; +import { InsufficientAccountFunds, NotFound } from 'lib/errors'; +import { authorSchema } from 'schemas/authorSchema'; +import { env } from 'schemas/envSchema'; +import { fileBudgetSchema, objectContentPointerSchema } from 'schemas/fileSchema'; +import type { Doc, Id } from './_generated/dataModel'; +import type { MutationCtx, QueryCtx } from './_generated/server'; +import { findReadCursor } from './reads.private'; +import { recordMutationAction, latestActionForFile, recentActionsForFile } from './reactor.private'; + +const fileTagInputSchema = z.object({ + key: z.string().min(1), + value: z.string(), +}); + +const createFileArgs = z.object({ + owner: zid('users'), + parent: zid('files').optional(), + name: z.string().min(1), + author: authorSchema, + content: z.string().optional(), + provider: z.string().min(1).optional(), + providerReference: z.string().min(1).optional(), + tags: z.array(fileTagInputSchema).default([]), + budget: fileBudgetSchema.optional(), + isPublic: z.boolean().optional(), + sourceOwner: zid('users').optional(), + sourceKey: z.string().min(1).optional(), + sourceFile: zid('files').optional(), + shouldAddInboxTag: z.boolean().default(true), + shouldCreateAction: z.boolean().default(true), +}); + +export const ensureFileOwner = defineQuery({ + args: z.object({ + fileId: zid('files'), + owner: zid('users'), + }), + handler: async (ctx, { fileId, owner }) => { + // + const file = await ctx.db.get(fileId); + if (!file || file.owner !== owner) throw NotFound(); + + return file; + }, +}); + +export const ensureFileVisible = defineQuery({ + args: z.object({ + fileId: zid('files'), + viewer: zid('users'), + }), + handler: async (ctx, { fileId, viewer }) => { + // + const file = await ctx.db.get(fileId); + if (!file) throw NotFound(); + if (file.owner === viewer || file.isPublic === true || (await hasPublicAncestor(ctx, { file }))) return file; + + throw NotFound(); + }, +}); + +export const findChildByName = defineQuery({ + args: z.object({ + owner: zid('users'), + parent: zid('files').optional(), + name: z.string().min(1), + }), + handler: async (ctx, { owner, parent, name }) => { + // + return await ctx.db + .query('files') + .withIndex('by_owner_parent_name', (q) => + q + .eq('owner', owner) // + .eq('parent', parent) + .eq('name', name), + ) + .unique(); + }, +}); + +export const createFile = defineMutation({ + args: createFileArgs, + handler: async (ctx, args) => { + // + const now = Date.now(); + if (args.parent) await ensureFileOwner(ctx, { fileId: args.parent, owner: args.owner }); + + const existing = await findChildByName(ctx, { + owner: args.owner, + parent: args.parent, + name: args.name, + }); + if (existing) throw new Error('A file with this name already exists here.'); + + const fileId = await ctx.db.insert('files', { + owner: args.owner, + parent: args.parent, + name: args.name, + author: args.author, + provider: args.provider, + providerReference: args.providerReference, + budget: args.budget, + isPublic: args.isPublic, + sourceOwner: args.sourceOwner, + sourceKey: args.sourceKey, + sourceFile: args.sourceFile, + createdAt: now, + updatedAt: now, + }); + + if (args.content !== undefined) { + await writeTextPointer(ctx, { + owner: args.owner, + fileId, + author: args.author, + text: args.content, + now, + }); + } + + const tags = mergeInitialTags({ + tags: args.tags, + shouldAddInboxTag: args.shouldAddInboxTag, + }); + + for (const tag of tags) { + await upsertTag(ctx, { + owner: args.owner, + file: fileId, + key: tag.key, + value: tag.value, + author: args.author, + shouldCreateAction: false, + }); + } + + if (args.shouldCreateAction) { + await recordMutationAction(ctx, { + owner: args.owner, + file: fileId, + author: args.author, + skillKey: createFileActionSkill(), + args: { + name: args.name, + parent: args.parent, + }, + result: { + text: `Created ${args.name}.`, + files: [ + { + file: fileId, + path: args.name, + }, + ], + }, + }); + } + + return fileId; + }, +}); + +export const listChildren = defineQuery({ + args: z.object({ + parent: zid('files'), + owner: zid('users'), + }), + handler: async (ctx, { parent, owner }) => { + // + await ensureFileOwner(ctx, { fileId: parent, owner }); + + return await ctx.db + .query('files') + .withIndex('by_parent', (q) => q.eq('parent', parent)) + .collect(); + }, +}); + +type FileTreeNode = { + file: Doc<'files'>; + children: FileTreeNode[]; +}; + +export const treeFile = defineQuery({ + args: z.object({ + root: zid('files'), + owner: zid('users'), + maxDepth: z.number().int().nonnegative().max(12).default(4), + }), + handler: async (ctx, { root, owner, maxDepth }): Promise => { + // + const file = await ensureFileOwner(ctx, { fileId: root, owner }); + return await buildTree(ctx, { + owner, + file, + depth: 0, + maxDepth, + }); + }, +}); + +export const findTags = defineQuery({ + args: z.object({ + file: zid('files'), + owner: zid('users'), + }), + handler: async (ctx, { file, owner }) => { + // + await ensureFileOwner(ctx, { fileId: file, owner }); + + return await ctx.db + .query('file_tags') + .withIndex('by_file', (q) => q.eq('file', file)) + .collect(); + }, +}); + +export const findFilesByTag = defineQuery({ + args: z.object({ + owner: zid('users'), + key: z.string().min(1), + value: z.string().optional(), + }), + handler: async (ctx, { owner, key, value }) => { + // + if (value !== undefined) { + const tags = await ctx.db + .query('file_tags') + .withIndex('by_owner_key_value', (q) => + q + .eq('owner', owner) // + .eq('key', key) + .eq('value', value), + ) + .collect(); + + return await loadTaggedFiles(ctx, { tags }); + } + + const tags = await ctx.db + .query('file_tags') + .withIndex('by_owner_key', (q) => + q + .eq('owner', owner) // + .eq('key', key), + ) + .collect(); + + return await loadTaggedFiles(ctx, { tags }); + }, +}); + +export const findInboxFiles = defineQuery({ + args: z.object({ + owner: zid('users'), + }), + handler: async (ctx, { owner }) => { + // + const tags = await ctx.db + .query('file_tags') + .withIndex('by_owner_key_value', (q) => + q + .eq('owner', owner) // + .eq('key', 'inbox') + .eq('value', 'true'), + ) + .collect(); + + return await loadTaggedFiles(ctx, { tags }); + }, +}); + +export const readFile = defineQuery({ + args: z.object({ + fileId: zid('files'), + owner: zid('users'), + recentActionLimit: z.number().int().positive().max(50).default(12), + }), + handler: async (ctx, { fileId, owner, recentActionLimit }) => { + // + const file = await ensureFileOwner(ctx, { fileId, owner }); + const tags = await findTags(ctx, { file: fileId, owner }); + const children = await listChildren(ctx, { parent: fileId, owner }); + const content = await renderCurrentContent(ctx, { file }); + const latestAction = await latestActionForFile(ctx, { file: fileId }); + const recentActions = await recentActionsForFile(ctx, { file: fileId, limit: recentActionLimit }); + const readCursor = await findReadCursor(ctx, { + user: owner, + file: fileId, + }); + + return { + file, + tags, + content, + children, + latestAction, + recentActions, + readCursor, + derived: deriveState({ latestAction, recentActions, readCursor }), + }; + }, +}); + +export const catFile = defineQuery({ + args: z.object({ + fileId: zid('files'), + owner: zid('users'), + }), + handler: async (ctx, { fileId, owner }) => { + // + const file = await ensureFileOwner(ctx, { fileId, owner }); + return await renderCurrentContent(ctx, { file }); + }, +}); + +export const catVisibleFile = defineQuery({ + args: z.object({ + fileId: zid('files'), + viewer: zid('users'), + }), + handler: async (ctx, { fileId, viewer }) => { + // + const file = await ensureFileVisible(ctx, { fileId, viewer }); + return await renderCurrentContent(ctx, { file }); + }, +}); + +export const headFile = defineQuery({ + args: z.object({ + fileId: zid('files'), + owner: zid('users'), + lines: z.number().int().positive().max(500).default(40), + }), + handler: async (ctx, { fileId, owner, lines }) => { + // + const file = await ensureFileOwner(ctx, { fileId, owner }); + if (file.currentContent?.kind === 'object') return await renderCurrentContent(ctx, { file }); + + const text = await readCurrentText(ctx, { file }); + return text.split('\n').slice(0, lines).join('\n'); + }, +}); + +export const tailFile = defineQuery({ + args: z.object({ + fileId: zid('files'), + owner: zid('users'), + lines: z.number().int().positive().max(500).default(40), + }), + handler: async (ctx, { fileId, owner, lines }) => { + // + const file = await ensureFileOwner(ctx, { fileId, owner }); + if (file.currentContent?.kind === 'object') return await renderCurrentContent(ctx, { file }); + + const text = await readCurrentText(ctx, { file }); + return text.split('\n').slice(-lines).join('\n'); + }, +}); + +export const writeFileContent = defineMutation({ + args: z.object({ + fileId: zid('files'), + owner: zid('users'), + author: authorSchema, + content: z.string(), + shouldCreateAction: z.boolean().default(true), + }), + handler: async (ctx, { fileId, owner, author, content, shouldCreateAction }) => { + // + const file = await ensureFileOwner(ctx, { fileId, owner }); + const now = Date.now(); + + await writeTextPointer(ctx, { + owner, + fileId, + author, + text: content, + now, + }); + + if (shouldCreateAction) { + await recordMutationAction(ctx, { + owner, + file: fileId, + author, + skillKey: 'updateFileContent', + args: {}, + result: { + text: `Updated ${file.name}.`, + files: [ + { + file: fileId, + path: file.name, + }, + ], + }, + }); + } + + return `Updated ${file.name}.`; + }, +}); + +export const moveFile = defineMutation({ + args: z.object({ + fileId: zid('files'), + owner: zid('users'), + author: authorSchema, + newParent: zid('files').nullable().optional(), + newName: z.string().min(1).optional(), + shouldCreateAction: z.boolean().default(true), + }), + handler: async (ctx, { fileId, owner, author, newParent, newName, shouldCreateAction }) => { + // + const file = await ensureFileOwner(ctx, { fileId, owner }); + const nextParent = newParent === undefined ? file.parent : (newParent ?? undefined); + if (nextParent) await ensureFileOwner(ctx, { fileId: nextParent, owner }); + + const nextName = newName ?? file.name; + const existing = await findChildByName(ctx, { + owner, + parent: nextParent, + name: nextName, + }); + if (existing && existing._id !== fileId) throw new Error('A file with this name already exists here.'); + + const oldPath = await buildPath(ctx, { file }); + const newPath = await buildPathForParts(ctx, { + owner, + parent: nextParent, + name: nextName, + }); + const now = Date.now(); + + await ctx.db.patch(fileId, { + parent: nextParent, + name: nextName, + updatedAt: now, + }); + + if (shouldCreateAction) { + await recordMutationAction(ctx, { + owner, + file: fileId, + author, + skillKey: 'move', + args: { + parent: nextParent, + name: nextName, + }, + result: { + text: `${oldPath} -> ${newPath}`, + files: [ + { + file: fileId, + path: newPath, + }, + ], + }, + }); + } + + return `${oldPath} -> ${newPath}`; + }, +}); + +export const upsertTag = defineMutation({ + args: z.object({ + owner: zid('users'), + file: zid('files'), + key: z.string().min(1), + value: z.string(), + author: authorSchema, + shouldCreateAction: z.boolean().default(true), + }), + handler: async (ctx, { owner, file, key, value, author, shouldCreateAction }) => { + // + await ensureFileOwner(ctx, { fileId: file, owner }); + + const existing = await ctx.db + .query('file_tags') + .withIndex('by_file_key', (q) => q.eq('file', file).eq('key', key)) + .unique(); + + if (existing) { + await ctx.db.patch(existing._id, { value, author, createdAt: Date.now() }); + + if (shouldCreateAction && existing.value !== value) { + await recordMutationAction(ctx, { + owner, + file, + author, + skillKey: 'tag', + args: { key, value }, + result: { + text: `Updated tag ${key}.`, + files: [], + }, + }); + } + + return existing._id; + } + + const tagId = await ctx.db.insert('file_tags', { + owner, + file, + key, + value, + author, + createdAt: Date.now(), + }); + + if (shouldCreateAction) { + await recordMutationAction(ctx, { + owner, + file, + author, + skillKey: 'tag', + args: { key, value }, + result: { + text: `Added tag ${key}.`, + files: [], + }, + }); + } + + return tagId; + }, +}); + +export const removeTag = defineMutation({ + args: z.object({ + owner: zid('users'), + file: zid('files'), + key: z.string().min(1), + author: authorSchema, + shouldCreateAction: z.boolean().default(true), + }), + handler: async (ctx, { owner, file, key, author, shouldCreateAction }) => { + // + await ensureFileOwner(ctx, { fileId: file, owner }); + + const existing = await ctx.db + .query('file_tags') + .withIndex('by_file_key', (q) => q.eq('file', file).eq('key', key)) + .unique(); + + if (!existing) return false; + + await ctx.db.delete(existing._id); + + if (shouldCreateAction) { + await recordMutationAction(ctx, { + owner, + file, + author, + skillKey: 'untag', + args: { key }, + result: { + text: `Removed tag ${existing.key}.`, + files: [], + }, + }); + } + + return true; + }, +}); + +export const setFileTags = defineMutation({ + args: z.object({ + owner: zid('users'), + file: zid('files'), + author: authorSchema, + tags: z.array(fileTagInputSchema), + shouldRemoveInboxTag: z.boolean().default(false), + shouldCreateAction: z.boolean().default(true), + actionSkill: z.string().min(1).default('setTags'), + }), + handler: async (ctx, { owner, file, author, tags, shouldRemoveInboxTag, shouldCreateAction, actionSkill }) => { + // + const updates = []; + + if (shouldRemoveInboxTag) { + const removed = await removeTag(ctx, { + owner, + file, + key: 'inbox', + author, + shouldCreateAction: false, + }); + if (removed) updates.push('removed inbox=true'); + } + + for (const tag of tags) { + const existing = await ctx.db + .query('file_tags') + .withIndex('by_file_key', (q) => q.eq('file', file).eq('key', tag.key)) + .unique(); + await upsertTag(ctx, { + owner, + file, + key: tag.key, + value: tag.value, + author, + shouldCreateAction: false, + }); + if (!existing) { + updates.push(`added ${tag.key}=${tag.value}`); + } else if (existing.value !== tag.value) { + updates.push(`updated ${tag.key}=${existing.value} -> ${tag.value}`); + } + } + + const summary = updates.join('\n'); + if (shouldCreateAction && summary) { + await recordMutationAction(ctx, { + owner, + file, + author, + skillKey: actionSkill, + args: { tags }, + result: { + text: summary, + files: [], + }, + }); + } + + return summary; + }, +}); + +export const clearFileTag = defineMutation({ + args: z.object({ + owner: zid('users'), + file: zid('files'), + author: authorSchema, + key: z.string().min(1), + actionSkill: z.string().min(1).default('removeTag'), + shouldCreateAction: z.boolean().default(true), + }), + handler: async (ctx, { owner, file, author, key, actionSkill, shouldCreateAction }) => { + // + const existing = await ctx.db + .query('file_tags') + .withIndex('by_file_key', (q) => q.eq('file', file).eq('key', key)) + .unique(); + const removed = await removeTag(ctx, { + owner, + file, + author, + key, + shouldCreateAction: false, + }); + + if (!removed || !existing) return ''; + + const summary = `Removed tag ${existing.key}.`; + if (shouldCreateAction) { + await recordMutationAction(ctx, { + owner, + file, + author, + skillKey: actionSkill, + args: { key }, + result: { + text: summary, + files: [], + }, + }); + } + + return summary; + }, +}); + +export const copyFile = defineMutation({ + args: z.object({ + owner: zid('users'), + source: zid('files'), + parent: zid('files').optional(), + name: z.string().min(1), + author: authorSchema, + }), + handler: async (ctx, { owner, source, parent, name, author }) => { + // + const sourceFile = await ensureFileVisible(ctx, { fileId: source, viewer: owner }); + let content: string | undefined; + if (sourceFile.currentContent?.kind === 'text') { + content = await readCurrentText(ctx, { file: sourceFile }); + } + + const tags = await ctx.db + .query('file_tags') + .withIndex('by_file', (q) => q.eq('file', source)) + .collect(); + const fileId = await createFile(ctx, { + owner, + parent, + name, + author, + content, + provider: sourceFile.provider, + providerReference: sourceFile.providerReference, + tags: tags.map((tag) => ({ key: tag.key, value: tag.value })), + shouldAddInboxTag: false, + shouldCreateAction: false, + sourceOwner: sourceFile.owner, + sourceKey: sourceFile.sourceKey, + sourceFile: source, + }); + + if (sourceFile.currentContent?.kind === 'object') { + await ctx.db.patch(fileId, { + currentContent: sourceFile.currentContent, + updatedAt: Date.now(), + }); + } + + await recordMutationAction(ctx, { + owner, + file: fileId, + author, + skillKey: 'copyFile', + args: { + source, + parent, + name, + }, + result: { + text: `Copied ${name}.`, + files: [ + { + file: fileId, + path: name, + }, + ], + }, + }); + + return fileId; + }, +}); + +export const adjustFileBudget = defineMutation({ + args: z.object({ + owner: zid('users'), + file: zid('files'), + author: authorSchema, + amount: z.bigint(), + }), + handler: async (ctx, { owner, file, author, amount }) => { + // + const fileRecord = await ensureFileOwner(ctx, { fileId: file, owner }); + const user = await ctx.db.get(owner); + if (!user) throw NotFound(); + + const budget = fileRecord.budget ?? { + total: 0n, + available: 0n, + reserved: 0n, + spent: 0n, + }; + + if (amount > 0n) { + const committed = user.committedBudgetUSD ?? 0n; + const uncommittedBalance = (user.balanceUSD ?? 0n) - committed; + if (uncommittedBalance < amount) + throw InsufficientAccountFunds('Not enough wallet balance to commit this energy.'); + + await ctx.db.patch(owner, { + committedBudgetUSD: committed + amount, + }); + await ctx.db.patch(file, { + budget: { + ...budget, + total: budget.total + amount, + available: budget.available + amount, + }, + updatedAt: Date.now(), + }); + } + + if (amount < 0n) { + const decrease = -amount; + if (budget.available < decrease) throw new Error('Cannot decrease more than the available file budget.'); + + await ctx.db.patch(owner, { + committedBudgetUSD: (user.committedBudgetUSD ?? 0n) - decrease, + }); + await ctx.db.patch(file, { + budget: { + ...budget, + total: budget.total - decrease, + available: budget.available - decrease, + }, + updatedAt: Date.now(), + }); + } + + await recordMutationAction(ctx, { + owner, + file, + author, + skillKey: 'updateBudget', + args: { amount }, + result: { + text: `Updated budget by ${amount}.`, + files: [], + }, + }); + }, +}); + +export const releaseAvailableFileBudget = defineMutation({ + args: z.object({ + owner: zid('users'), + file: zid('files'), + author: authorSchema, + actionSkill: z.string().min(1), + shouldCreateAction: z.boolean().default(true), + }), + handler: async (ctx, { owner, file, author, actionSkill, shouldCreateAction }) => { + // + const fileRecord = await ensureFileOwner(ctx, { fileId: file, owner }); + const budget = fileRecord.budget; + if (!budget || budget.available === 0n) return ''; + + const user = await ctx.db.get(owner); + if (!user) throw NotFound(); + + await ctx.db.patch(owner, { + committedBudgetUSD: safeSubtract(user.committedBudgetUSD ?? 0n, budget.available), + }); + await ctx.db.patch(file, { + budget: { + ...budget, + total: safeSubtract(budget.total, budget.available), + available: 0n, + }, + updatedAt: Date.now(), + }); + + const summary = `Released ${budget.available} available budget.`; + if (shouldCreateAction) { + await recordMutationAction(ctx, { + owner, + file, + author, + skillKey: actionSkill, + args: { + amount: -budget.available, + reason: 'release available energy', + }, + result: { + text: summary, + files: [], + }, + }); + } + + return summary; + }, +}); + +export const setObjectContentPointer = defineMutation({ + args: z.object({ + owner: zid('users'), + fileId: zid('files'), + author: authorSchema, + pointer: objectContentPointerSchema, + }), + handler: async (ctx, { owner, fileId, author, pointer }) => { + // + await ensureFileOwner(ctx, { fileId, owner }); + const now = Date.now(); + + await ctx.db.patch(fileId, { + currentContent: pointer, + updatedAt: now, + }); + + await recordMutationAction(ctx, { + owner, + file: fileId, + author, + skillKey: 'writeObject', + args: { + storageKey: pointer.storageKey, + size: pointer.size, + contentType: pointer.contentType, + }, + result: { + text: `Updated object content ${pointer.storageKey}.`, + files: [ + { + file: fileId, + path: pointer.storageKey, + size: pointer.size, + contentType: pointer.contentType, + }, + ], + }, + }); + }, +}); + +async function writeTextPointer( + ctx: MutationCtx, + args: { + owner: Id<'users'>; + fileId: Id<'files'>; + author: z.infer; + text: string; + now: number; + }, +) { + // + assertInlineTextSize(args.text); + + const contentId = await ctx.db.insert('file_contents', { + owner: args.owner, + file: args.fileId, + author: args.author, + text: args.text, + createdAt: args.now, + }); + + await ctx.db.patch(args.fileId, { + currentContent: { + kind: 'text', + content: contentId, + }, + updatedAt: args.now, + }); +} + +function assertInlineTextSize(text: string) { + // + const size = new TextEncoder().encode(text).byteLength; + if (size <= env.MAX_REACTOR_INLINE_CONTENT_BYTES) return; + + throw new Error('Inline text content is too large; use the object storage write path.'); +} + +async function readCurrentText( + ctx: QueryCtx | MutationCtx, + args: { + file: Doc<'files'>; + }, +): Promise { + // + const pointer = args.file.currentContent; + if (!pointer || pointer.kind !== 'text') return ''; + + const content = await ctx.db.get(pointer.content); + return content?.text ?? ''; +} + +export async function renderCurrentContent( + ctx: QueryCtx | MutationCtx, + args: { + file: Doc<'files'>; + }, +) { + // + const pointer = args.file.currentContent; + if (!pointer) return ''; + if (pointer.kind === 'text') return await readCurrentText(ctx, args); + + return [ + ``, + 'Use the file read path to load this object-backed content.', + '', + ].join('\n'); +} + +async function loadTaggedFiles( + ctx: QueryCtx | MutationCtx, + args: { + tags: Doc<'file_tags'>[]; + }, +): Promise[]> { + // + const files = []; + + for (const tag of args.tags) { + const file = await ctx.db.get(tag.file); + if (file) files.push(file); + } + + return files; +} + +async function buildTree( + ctx: QueryCtx, + args: { + owner: Id<'users'>; + file: Doc<'files'>; + depth: number; + maxDepth: number; + }, +): Promise { + // + if (args.depth >= args.maxDepth) { + return { + file: args.file, + children: [], + }; + } + + const children = await ctx.db + .query('files') + .withIndex('by_parent', (q) => q.eq('parent', args.file._id)) + .collect(); + const childTrees = []; + + for (const child of children) { + if (child.owner !== args.owner) continue; + childTrees.push( + await buildTree(ctx, { + owner: args.owner, + file: child, + depth: args.depth + 1, + maxDepth: args.maxDepth, + }), + ); + } + + return { + file: args.file, + children: childTrees, + }; +} + +async function hasPublicAncestor( + ctx: QueryCtx | MutationCtx, + args: { + file: Doc<'files'>; + }, +) { + // + let parent = args.file.parent; + + for (let depth = 0; parent && depth < 64; depth += 1) { + const file = await ctx.db.get(parent); + if (!file) return false; + if (file.isPublic === true) return true; + parent = file.parent; + } + + return false; +} + +async function buildPath( + ctx: QueryCtx | MutationCtx, + args: { + file: Doc<'files'>; + }, +): Promise { + // + const parts = [args.file.name]; + let parent = args.file.parent; + + for (let depth = 0; parent && depth < 64; depth += 1) { + const file = await ctx.db.get(parent); + if (!file) break; + parts.unshift(file.name); + parent = file.parent; + } + + return `/${parts.join('/')}`; +} + +async function buildPathForParts( + ctx: QueryCtx | MutationCtx, + args: { + owner: Id<'users'>; + parent?: Id<'files'>; + name: string; + }, +): Promise { + // + if (!args.parent) return `/${args.name}`; + + const parent = await ensureFileOwner(ctx, { + fileId: args.parent, + owner: args.owner, + }); + const parentPath = await buildPath(ctx, { file: parent }); + + return `${parentPath}/${args.name}`; +} + +function mergeInitialTags(args: { tags: Array>; shouldAddInboxTag: boolean }) { + // + const tags = args.shouldAddInboxTag ? [{ key: 'inbox', value: 'true' }].concat(args.tags) : args.tags; + const byKey = new Map>(); + + for (const tag of tags) { + byKey.set(tag.key, tag); + } + + return Array.from(byKey.values()); +} + +function createFileActionSkill() { + // + return 'createFile'; +} + +function safeSubtract(value: bigint, amount: bigint) { + // + return value > amount ? value - amount : 0n; +} + +function deriveState(args: { + latestAction: Doc<'actions'> | null; + recentActions: Doc<'actions'>[]; + readCursor: Doc<'reads'> | null; +}) { + // + let isActing = false; + let isBlocked = false; + + for (const action of args.recentActions) { + if (action.status === 'running') isActing = true; + if (action.status === 'pending authorization') isBlocked = true; + } + + return { + latestActionIndex: args.latestAction?.index ?? -1, + lastReadActionIndex: args.readCursor?.lastReadActionIndex ?? -1, + isUnread: (args.latestAction?.index ?? -1) > (args.readCursor?.lastReadActionIndex ?? -1), + isActing, + isBlocked, + }; +} diff --git a/apps/meseeks/convex/files.ts b/apps/meseeks/convex/files.ts new file mode 100644 index 00000000..7230c548 --- /dev/null +++ b/apps/meseeks/convex/files.ts @@ -0,0 +1,694 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import { internalMutation, mutation, query } from 'lib/convex'; +import { NotFound } from 'lib/errors'; +import { fileBudgetSchema } from 'schemas/fileSchema'; +import { paginationOptionsSchema } from 'schemas/paginationOptionsSchema'; +import { createActionsForFile } from './actions'; +import { + adjustFileBudget, + catVisibleFile, + copyFile, + createFile, + ensureFileOwner, + ensureFileVisible, + findFilesByTag, + findInboxFiles, + findTags, + headFile, + listChildren, + moveFile, + readFile, + renderCurrentContent, + removeTag, + setFileTags, + tailFile, + treeFile, + upsertTag, + writeFileContent, +} from './files.private'; +import { findLoopByKey } from './loops.private'; +import { markFileRead } from './reads.private'; +import { latestActionForFile } from './reactor.private'; +import { getCurrentUser } from './users.private'; +import type { Doc, Id } from './_generated/dataModel'; +import type { MutationCtx, QueryCtx } from './_generated/server'; + +const zeroBudget = { + total: 0n, + available: 0n, + reserved: 0n, + spent: 0n, +}; + +const tagsArg = z.array( + z.object({ + key: z.string().min(1), + value: z.string(), + }), +); + +export const create = mutation({ + args: { + parent: zid('files').optional(), + name: z.string().min(1), + content: z.string().optional(), + tags: tagsArg.default([]), + budget: fileBudgetSchema.optional(), + shouldAddInboxTag: z.boolean().default(true), + }, + handler: async (ctx, args) => { + // + const currentUser = await getCurrentUser(ctx, {}); + + return await createFile(ctx, { + owner: currentUser._id, + parent: args.parent, + name: args.name, + author: currentUser._id, + content: args.content, + tags: args.tags, + budget: args.budget, + shouldAddInboxTag: args.shouldAddInboxTag, + }); + }, +}); + +export const findOne = query({ + args: { + fileId: zid('files'), + }, + handler: async (ctx, { fileId }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + const file = await ensureFileVisible(ctx, { fileId, viewer: currentUser._id }); + + return await fileCardFromFile(ctx, { + owner: currentUser._id, + file, + }); + }, +}); + +export const findAll = query({ + args: {}, + handler: async (ctx) => { + // + const currentUser = await getCurrentUser(ctx, {}); + const tags = await ctx.db + .query('file_tags') + .withIndex('by_owner_key_value', (q) => + q.eq('owner', currentUser._id).eq('key', 'kind').eq('value', 'task'), + ) + .collect(); + + return await fileCardsFromTags(ctx, { owner: currentUser._id, tags }); + }, +}); + +export const findAllPaginated = query({ + args: { + paginationOpts: paginationOptionsSchema, + }, + handler: async (ctx, { paginationOpts }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + const page = await ctx.db + .query('file_tags') + .withIndex('by_owner_key_value', (q) => + q.eq('owner', currentUser._id).eq('key', 'kind').eq('value', 'task'), + ) + .order('desc') + .paginate(paginationOpts); + + return { + ...page, + page: await fileCardsFromTags(ctx, { + owner: currentUser._id, + tags: page.page, + }), + }; + }, +}); + +export const findAllAtInboxPaginated = query({ + args: { + parentId: zid('files').optional(), + paginationOpts: paginationOptionsSchema, + }, + handler: async (ctx, { parentId, paginationOpts }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + + if (parentId) { + await ensureFileOwner(ctx, { + fileId: parentId, + owner: currentUser._id, + }); + const page = await ctx.db + .query('files') + .withIndex('by_parent', (q) => q.eq('parent', parentId)) + .order('desc') + .paginate(paginationOpts); + + return { + ...page, + page: await fileCardsFromFiles(ctx, { + owner: currentUser._id, + files: page.page, + }), + }; + } + + const page = await ctx.db + .query('file_tags') + .withIndex('by_owner_key_value', (q) => + q.eq('owner', currentUser._id).eq('key', 'inbox').eq('value', 'true'), + ) + .order('desc') + .paginate(paginationOpts); + + return { + ...page, + page: await fileCardsFromTags(ctx, { + owner: currentUser._id, + tags: page.page, + }), + }; + }, +}); + +export const add = mutation({ + args: { + message: z.string().min(1), + initialFunds: z.bigint(), + intelligence: z.string().optional(), + loopKey: z.string().min(1).nullable().optional(), + }, + handler: async (ctx, { message, initialFunds, intelligence, loopKey }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + const name = fileNameFromMessage(message); + const fileId = await createFile(ctx, { + owner: currentUser._id, + name, + author: currentUser._id, + content: message, + tags: [ + { key: 'kind', value: 'task' }, + { key: 'status', value: 'active' }, + ], + shouldAddInboxTag: false, + }); + + if (initialFunds > 0n) { + await adjustFileBudget(ctx, { + owner: currentUser._id, + file: fileId, + author: currentUser._id, + amount: initialFunds, + }); + } + + const selectedLoop = await loopForNewFile(ctx, { + owner: currentUser._id, + loopKey, + }); + const actionArgs: Record = { + text: message, + message, + }; + await createActionsForFile(ctx, { + owner: currentUser._id, + file: fileId, + loopKey: selectedLoop?.key, + intelligenceKey: intelligence, + skills: [ + { + skillKey: 'say', + args: actionArgs, + }, + ], + }); + + return fileId; + }, +}); + +export const read = query({ + args: { + fileId: zid('files'), + }, + handler: async (ctx, { fileId }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + return await readFile(ctx, { fileId, owner: currentUser._id, recentActionLimit: 16 }); + }, +}); + +export const cat = query({ + args: { + fileId: zid('files'), + }, + handler: async (ctx, { fileId }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + return await catVisibleFile(ctx, { fileId, viewer: currentUser._id }); + }, +}); + +export const ls = query({ + args: { + parent: zid('files'), + }, + handler: async (ctx, { parent }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + return await listChildren(ctx, { parent, owner: currentUser._id }); + }, +}); + +export const head = query({ + args: { + fileId: zid('files'), + lines: z.number().int().positive().max(500).default(40), + }, + handler: async (ctx, { fileId, lines }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + return await headFile(ctx, { fileId, owner: currentUser._id, lines }); + }, +}); + +export const tail = query({ + args: { + fileId: zid('files'), + lines: z.number().int().positive().max(500).default(40), + }, + handler: async (ctx, { fileId, lines }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + return await tailFile(ctx, { fileId, owner: currentUser._id, lines }); + }, +}); + +export const tree = query({ + args: { + root: zid('files'), + maxDepth: z.number().int().nonnegative().max(12).default(4), + }, + handler: async (ctx, { root, maxDepth }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + return await treeFile(ctx, { root, owner: currentUser._id, maxDepth }); + }, +}); + +export const updateContent = mutation({ + args: { + fileId: zid('files'), + content: z.string(), + }, + handler: async (ctx, { fileId, content }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + return await writeFileContent(ctx, { + fileId, + owner: currentUser._id, + author: currentUser._id, + content, + }); + }, +}); + +export const move = mutation({ + args: { + fileId: zid('files'), + newParent: zid('files').nullable().optional(), + }, + handler: async (ctx, { fileId, newParent }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + return await moveFile(ctx, { + fileId, + owner: currentUser._id, + author: currentUser._id, + newParent, + }); + }, +}); + +export const rename = mutation({ + args: { + fileId: zid('files'), + name: z.string().min(1), + }, + handler: async (ctx, { fileId, name }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + return await moveFile(ctx, { + fileId, + owner: currentUser._id, + author: currentUser._id, + newName: name, + }); + }, +}); + +export const tag = mutation({ + args: { + file: zid('files'), + key: z.string().min(1), + value: z.string(), + }, + handler: async (ctx, args) => { + // + const currentUser = await getCurrentUser(ctx, {}); + return await upsertTag(ctx, { + owner: currentUser._id, + file: args.file, + key: args.key, + value: args.value, + author: currentUser._id, + }); + }, +}); + +export const untag = mutation({ + args: { + file: zid('files'), + key: z.string().min(1), + }, + handler: async (ctx, args) => { + // + const currentUser = await getCurrentUser(ctx, {}); + return await removeTag(ctx, { + owner: currentUser._id, + file: args.file, + key: args.key, + author: currentUser._id, + }); + }, +}); + +export const setTags = mutation({ + args: { + file: zid('files'), + tags: tagsArg, + shouldRemoveInboxTag: z.boolean().default(false), + }, + handler: async (ctx, { file, tags, shouldRemoveInboxTag }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + return await setFileTags(ctx, { + owner: currentUser._id, + file, + author: currentUser._id, + tags, + shouldRemoveInboxTag, + }); + }, +}); + +export const inbox = query({ + args: {}, + handler: async (ctx) => { + // + const currentUser = await getCurrentUser(ctx, {}); + return await findInboxFiles(ctx, { owner: currentUser._id }); + }, +}); + +export const queryByTag = query({ + args: { + key: z.string().min(1), + value: z.string().optional(), + }, + handler: async (ctx, { key, value }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + return await findFilesByTag(ctx, { owner: currentUser._id, key, value }); + }, +}); + +export const copy = mutation({ + args: { + source: zid('files'), + parent: zid('files').optional(), + name: z.string().min(1), + }, + handler: async (ctx, { source, parent, name }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + return await copyFile(ctx, { + owner: currentUser._id, + source, + parent, + name, + author: currentUser._id, + }); + }, +}); + +export const updateBudget = mutation({ + args: { + file: zid('files'), + amount: z.bigint(), + }, + handler: async (ctx, { file, amount }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + return await adjustFileBudget(ctx, { + owner: currentUser._id, + file, + author: currentUser._id, + amount, + }); + }, +}); + +export const markAsRead = mutation({ + args: { + fileId: zid('files'), + }, + handler: async (ctx, { fileId }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + await ensureFileOwner(ctx, { + fileId, + owner: currentUser._id, + }); + const latest = await latestActionForFile(ctx, { file: fileId }); + + return await markFileRead(ctx, { + user: currentUser._id, + file: fileId, + lastReadActionIndex: latest?.index ?? 0, + }); + }, +}); + +export const _setStatus = internalMutation({ + args: { + fileId: zid('files'), + newStatus: z.enum(['active', 'done', 'discarded']), + }, + handler: async (ctx, { fileId, newStatus }) => { + // + const file = await ctx.db.get(fileId); + if (!file) throw NotFound(); + await setFileTags(ctx, { + owner: file.owner, + file: fileId, + author: file.owner, + tags: [{ key: 'status', value: newStatus }], + shouldCreateAction: false, + }); + }, +}); + +export const _updateContent = internalMutation({ + args: { + owner: zid('users'), + fileId: zid('files'), + name: z.string().optional(), + content: z.string().optional(), + summary: z.string().optional(), + }, + handler: async (ctx, { owner, fileId, name, content }) => { + // + await ensureFileOwner(ctx, { + fileId, + owner, + }); + if (name) { + await moveFile(ctx, { + owner, + fileId, + author: owner, + newName: name, + }); + } + if (content !== undefined) { + await writeFileContent(ctx, { + owner, + fileId, + author: owner, + content, + }); + } + }, +}); + +async function loopForNewFile( + ctx: MutationCtx, + args: { + owner: Id<'users'>; + loopKey?: string | null; + }, +) { + // + if (args.loopKey === null) return undefined; + + if (args.loopKey) { + return await findLoopByKey(ctx, { + owner: args.owner, + key: args.loopKey, + }); + } + + return await findLoopByKey(ctx, { owner: args.owner, key: '@pro/Seek' }); +} + +async function fileCardsFromTags( + ctx: QueryCtx | MutationCtx, + args: { + owner: Id<'users'>; + tags: Doc<'file_tags'>[]; + }, +) { + // + const files = []; + + for (const tag of args.tags) { + const file = await ctx.db.get(tag.file); + if (file && file.owner === args.owner) files.push(file); + } + + return await fileCardsFromFiles(ctx, { + owner: args.owner, + files, + }); +} + +async function fileCardsFromFiles( + ctx: QueryCtx | MutationCtx, + args: { + owner: Id<'users'>; + files: Doc<'files'>[]; + }, +) { + // + const files = []; + + for (const file of args.files) { + if (file.owner !== args.owner) continue; + files.push( + await fileCardFromFile(ctx, { + owner: args.owner, + file, + }), + ); + } + + return files; +} + +async function fileCardFromFile( + ctx: QueryCtx | MutationCtx, + args: { + owner: Id<'users'>; + file: Doc<'files'>; + }, +) { + // + const tags = await findTags(ctx, { + file: args.file._id, + owner: args.owner, + }); + const content = await renderCurrentContent(ctx, { file: args.file }); + const statusTag = tagValue(tags, 'status'); + const latest = await latestActionForFile(ctx, { file: args.file._id }); + const read = await ctx.db + .query('reads') + .withIndex('by_user_file', (q) => q.eq('user', args.owner).eq('file', args.file._id)) + .unique(); + const running = await ctx.db + .query('actions') + .withIndex('by_file_status', (q) => q.eq('file', args.file._id).eq('status', 'running')) + .first(); + const blocked = await ctx.db + .query('actions') + .withIndex('by_file_status', (q) => q.eq('file', args.file._id).eq('status', 'pending authorization')) + .first(); + const status = fileStatus({ + statusTag, + latestActionIndex: latest?.index ?? -1, + lastReadActionIndex: read?.lastReadActionIndex ?? -1, + isActing: Boolean(running), + isBlocked: Boolean(blocked), + }); + + return { + _id: args.file._id, + _creationTime: args.file._creationTime, + owner: args.file.owner, + parent: args.file.parent, + name: args.file.name, + content, + summary: undefined, + status, + isActive: status !== 'done' && status !== 'discarded', + energyBudget: fileBudgetSchema.parse(args.file.budget ?? zeroBudget), + budget: args.file.budget, + file: args.file, + tags, + updatedAt: args.file.updatedAt, + createdAt: args.file.createdAt, + }; +} + +function fileStatus(args: { + statusTag: string | undefined; + latestActionIndex: number; + lastReadActionIndex: number; + isActing: boolean; + isBlocked: boolean; +}) { + // + const statusSchema = z.enum(['idle', 'unread', 'acting', 'blocked', 'done', 'discarded']); + + if (args.statusTag === 'done') return statusSchema.parse('done'); + if (args.statusTag === 'discarded') return statusSchema.parse('discarded'); + if (args.isBlocked) return statusSchema.parse('blocked'); + if (args.isActing) return statusSchema.parse('acting'); + if (args.latestActionIndex > args.lastReadActionIndex) return statusSchema.parse('unread'); + + return statusSchema.parse('idle'); +} + +function tagValue(tags: Doc<'file_tags'>[], key: string) { + // + const tag = tags.find((candidate) => candidate.key === key); + return tag?.value; +} + +function fileNameFromMessage(message: string) { + // + const firstLine = message.trim().split('\n')[0] ?? 'Untitled file'; + const clean = firstLine.replace(/\s+/g, ' ').trim(); + if (!clean) return 'Untitled file'; + if (clean.length <= 60) return clean; + + return `${clean.slice(0, 57).trim()}...`; +} diff --git a/apps/meseeks/convex/loops.private.ts b/apps/meseeks/convex/loops.private.ts new file mode 100644 index 00000000..3c70ac97 --- /dev/null +++ b/apps/meseeks/convex/loops.private.ts @@ -0,0 +1,278 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import { intelligences, managedLoops, recommendedIntelligences } from 'lib/proDefinitions'; +import { defineMutation, defineQuery } from 'lib/convex'; +import { NotFound } from 'lib/errors'; +import { authorSchema } from 'schemas/authorSchema'; +import type { Doc, Id } from './_generated/dataModel'; +import type { QueryCtx } from './_generated/server'; +import { recordMutationAction } from './reactor.private'; + +export const seedManagedLoops = defineMutation({ + args: z.object({ + owner: zid('users'), + author: authorSchema, + auditFile: zid('files'), + }), + handler: async (ctx, { owner, author, auditFile }) => { + // + const loopIds: Id<'loops'>[] = []; + + for (const loop of managedLoops) { + const visual = loop.visual ?? { + icon: 'circle', + color: 'neutral', + tint: 'neutral', + }; + const existing = await ctx.db + .query('loops') + .withIndex('by_owner_key', (q) => q.eq('owner', owner).eq('key', loop.key)) + .unique(); + + if (existing) { + if ( + existing.name === loop.name && + existing.isPublic === true && + existing.defaultIntelligenceKey === loop.defaultIntelligenceKey && + areJsonRecordsEqual(existing.visual, visual) + ) { + loopIds.push(existing._id); + continue; + } + + await ctx.db.patch(existing._id, { + name: loop.name, + description: loop.description, + isPublic: true, + defaultIntelligenceKey: loop.defaultIntelligenceKey, + visual, + updatedAt: Date.now(), + }); + await recordMutationAction(ctx, { + owner, + file: auditFile, + author, + skillKey: 'updateLoop', + args: { + key: loop.key, + }, + result: { + text: `Updated loop ${loop.key}.`, + files: [], + }, + }); + loopIds.push(existing._id); + continue; + } + + const loopId = await ctx.db.insert('loops', { + owner, + key: loop.key, + name: loop.name, + description: loop.description, + isPublic: true, + defaultIntelligenceKey: loop.defaultIntelligenceKey, + visual, + author, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + await recordMutationAction(ctx, { + owner, + file: auditFile, + author, + skillKey: 'createLoop', + args: { + key: loop.key, + }, + result: { + text: `Created loop ${loop.key}.`, + files: [], + }, + }); + loopIds.push(loopId); + } + + return loopIds; + }, +}); + +export const createLoop = defineMutation({ + args: z.object({ + owner: zid('users'), + key: z.string().min(1), + name: z.string().min(1), + description: z.string().default(''), + defaultIntelligenceKey: z.string().min(1).optional(), + visual: z + .object({ + icon: z.string().min(1), + color: z.string().min(1), + tint: z.string().min(1), + }) + .default({ + icon: 'circle', + color: 'zinc', + tint: 'zinc', + }), + author: authorSchema, + auditFile: zid('files'), + }), + handler: async (ctx, { owner, key, name, description, defaultIntelligenceKey, visual, author, auditFile }) => { + // + const existing = await ctx.db + .query('loops') + .withIndex('by_owner_key', (q) => q.eq('owner', owner).eq('key', key)) + .unique(); + if (existing) throw new Error('A loop with this key already exists.'); + + const now = Date.now(); + const loopId = await ctx.db.insert('loops', { + owner, + key, + name, + description, + defaultIntelligenceKey, + visual, + author, + createdAt: now, + updatedAt: now, + }); + const actionId = await recordMutationAction(ctx, { + owner, + file: auditFile, + author, + skillKey: 'createLoop', + args: { + key, + }, + result: { + text: `Created loop ${key}.`, + files: [], + }, + }); + + return { + loopId, + actionId, + }; + }, +}); + +function areJsonRecordsEqual(left: unknown, right: unknown) { + // + return JSON.stringify(left) === JSON.stringify(right); +} + +export const findLoopByKey = defineQuery({ + args: z.object({ + owner: zid('users'), + key: z.string().min(1), + }), + handler: async (ctx, { owner, key }) => { + // + const owned = await ctx.db + .query('loops') + .withIndex('by_owner_key', (q) => q.eq('owner', owner).eq('key', key)) + .unique(); + if (owned) return owned; + + return await publicProLoopByKey(ctx, { owner, key }); + }, +}); + +export const listLoops = defineQuery({ + args: z.object({ + owner: zid('users'), + }), + handler: async (ctx, { owner }) => { + // + const owned = await ctx.db + .query('loops') + .withIndex('by_owner_key', (q) => q.eq('owner', owner)) + .collect(); + const proLoops = await publicProLoops(ctx, { owner }); + const keys = new Set(owned.map((loop) => loop.key)); + const visible = owned.slice(); + + for (const loop of proLoops) { + if (keys.has(loop.key)) continue; + visible.push(loop); + } + + return visible; + }, +}); + +export const findIntelligenceOptions = defineQuery({ + args: z.object({}), + handler: async () => ({ + recommended: recommendedIntelligences, + intelligences, + }), +}); + +export const resolveLoop = defineQuery({ + args: z.object({ + owner: zid('users'), + loop: zid('loops').optional(), + loopKey: z.string().min(1).nullable().optional(), + }), + handler: async (ctx, { owner, loop, loopKey }) => { + // + if (loop) { + const record = await ctx.db.get(loop); + if (!record || !isLoopVisibleToOwner({ loop: record, owner })) throw NotFound(); + return record; + } + + if (!loopKey) return undefined; + + return await findLoopByKey(ctx, { owner, key: loopKey }); + }, +}); + +async function publicProLoops(ctx: QueryCtx, args: { owner: Id<'users'> }): Promise[]> { + // + const loops = await ctx.db + .query('loops') + .withIndex('by_public_key', (q) => q.eq('isPublic', true)) + .collect(); + + return dedupePublicLoops(loops).filter((loop) => loop.owner !== args.owner); +} + +async function publicProLoopByKey( + ctx: QueryCtx, + args: { owner: Id<'users'>; key: string }, +): Promise | null> { + // + const loops = await ctx.db + .query('loops') + .withIndex('by_public_key', (q) => q.eq('isPublic', true).eq('key', args.key)) + .collect(); + + return dedupePublicLoops(loops).find((loop) => loop.owner !== args.owner) ?? null; +} + +export function isLoopVisibleToOwner(args: { loop: Doc<'loops'>; owner: Id<'users'> }) { + // + if (args.loop.owner === args.owner) return true; + + return args.loop.isPublic === true; +} + +function dedupePublicLoops(loops: Doc<'loops'>[]) { + // + const byKey = new Map>(); + const ordered = loops + .slice() + .sort((left, right) => right.updatedAt - left.updatedAt || right._creationTime - left._creationTime); + + for (const loop of ordered) { + if (byKey.has(loop.key)) continue; + byKey.set(loop.key, loop); + } + + return Array.from(byKey.values()); +} diff --git a/apps/meseeks/convex/loops.ts b/apps/meseeks/convex/loops.ts new file mode 100644 index 00000000..5a2e5340 --- /dev/null +++ b/apps/meseeks/convex/loops.ts @@ -0,0 +1,21 @@ +import { query } from 'lib/convex'; +import { findIntelligenceOptions, listLoops } from './loops.private'; +import { getCurrentUser } from './users.private'; + +export const findAll = query({ + args: {}, + handler: async (ctx) => { + // + const currentUser = await getCurrentUser(ctx, {}); + const loops = await listLoops(ctx, { owner: currentUser._id }); + + return { + loops, + }; + }, +}); + +export const intelligenceOptions = query({ + args: {}, + handler: findIntelligenceOptions, +}); diff --git a/apps/meseeks/convex/magicRock.private.ts b/apps/meseeks/convex/magicRock.private.ts index cb11ed7c..5adecacd 100644 --- a/apps/meseeks/convex/magicRock.private.ts +++ b/apps/meseeks/convex/magicRock.private.ts @@ -1,767 +1,90 @@ -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 { 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 { 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 { defineAction } from 'lib/convex'; +import { Unauthorized } from 'lib/errors'; 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'; -// >be human -// >dig shiny rocks from ground -// >grind rocks into powder -// >transform rock powder into rock wafers -// >enchant wafers with lightning -// >rocks can now do math -// >use rocks to exchange information globally -// >combine global information into new enchantments -// >rocks can think now -// >ask magic rock questions -// >magic rock knows everything -// >delegate all tasks to magic rocks -// >tfw automation achieves infinite productivity -// >singularity.png -// >mfw humanity peaked by tricking rocks into thinking +const dictionary = [ + 'PRO', // + 'DeepSeek', + 'Qwen', + 'GPT', +].join(','); -const moonshot = createOpenAICompatible({ - name: 'moonshot', - apiKey: env.MOONSHOT_API_KEY, - baseURL: 'https://api.moonshot.ai/v1', -}); - -const inception = createOpenAICompatible({ - name: 'inception', - apiKey: env.INCEPTION_API_KEY, - baseURL: 'https://api.inceptionlabs.ai/v1', -}); - -function estimateTokenCount(message: ModelMessage): number { - // - 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' } } }), - }; - - 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'), +const baseUrl = 'https://api.mistral.ai/v1'; +const transcriptionIntelligence = 'voxtral-mini-latest'; - // 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'), - }; - - 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> { - // - 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 audioArrayBufferSchema = z.unknown().transform((value, ctx) => { // - 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); -} + if (value instanceof ArrayBuffer) return value; -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, + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Expected an ArrayBuffer', }); - // 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(''), - }; -} - -// 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); + return z.NEVER; +}); - // Single-pass parsing that handles both escaped and normal variables correctly - result = parseAndReplaceVariables(result, task, action, userInfo, taskSchedules); +const audioContentTypeSchema = z + .string() + .trim() + .max(100) + .regex(/^audio\/[a-z0-9.+-]+(?:;.*)?$/i) + .optional(); - return result; -} +export const runMagicRock = defineAction({ + args: z.object({}), + handler: async () => ({ ok: true }), +}); -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) => { +export const transcribeAudio = defineAction({ + args: z.object({ + audio: audioArrayBufferSchema, + contentType: audioContentTypeSchema, + }), + handler: async (ctx, { audio, contentType }) => { // - // 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 { - // - if (!text.includes('{{allSkills}}')) return text; - - const list = await ctx.runQuery(internal.skills._findAllKeys, { - userId, - }); - - 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 }; + const identity = await ctx.auth.getUserIdentity(); + if (!identity) throw 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', transcriptionIntelligence); + 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}`, }, - ) => 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; - } -} - -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, + body: formData, }); - if (schedules.length === 0) { - return 'No active schedules for this task.'; + 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}`); } - 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}`; + const json = await response.json(); + const result = z.object({ text: z.string() }).parse(json); + const transcription = result.text.trim(); - 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.'; - } -} - -function dateOrNever(date: number | undefined) { - // - if (!date) return 'never'; + console.debug('Transcription result', { + intelligence: transcriptionIntelligence, + baseUrl, + characterCount: transcription.length, + contextBias: dictionary, + text: transcription, + }); - return new Date(date).toISOString(); -} + return transcription; + }, +}); diff --git a/apps/meseeks/convex/magicRock.tsx b/apps/meseeks/convex/magicRock.tsx index faa9f4ea..aa082f9c 100644 --- a/apps/meseeks/convex/magicRock.tsx +++ b/apps/meseeks/convex/magicRock.tsx @@ -1,85 +1,12 @@ -// 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; +import { runMagicRock, transcribeAudio } from './magicRock.private'; - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Expected an ArrayBuffer', - }); - - return z.NEVER; +export const run = action({ + args: runMagicRock.args.shape, + handler: runMagicRock, }); -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; - }, + args: transcribeAudio.args.shape, + handler: transcribeAudio, }); diff --git a/apps/meseeks/convex/migrations.ts b/apps/meseeks/convex/migrations.ts index d901b8f8..804c8699 100644 --- a/apps/meseeks/convex/migrations.ts +++ b/apps/meseeks/convex/migrations.ts @@ -1,53 +1,5 @@ import { Migrations } from '@convex-dev/migrations'; -import { components, internal } from './_generated/api.js'; +import { components } from './_generated/api.js'; import { DataModel } from './_generated/dataModel.js'; -import { enableSkill } from './skills.private'; export const migrations = new Migrations(components.migrations); - -// TODO: break down into files, stop removing them thats dumb - -// 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, - skillKey, - }); - } - - console.info(`Enabled ${skillsToEnable.length} skills for user ${doc._id}`); - - return doc; // return unchanged - }, -}); - -// Migration to backfill action_details with empty history array -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 { - ...doc, - llm: { - ...doc.llm, - history: [], - }, - }; - } - } - return doc; // return unchanged for hard skills or if history already exists - }, -}); - -// 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/objectStorage.private.ts b/apps/meseeks/convex/objectStorage.private.ts new file mode 100644 index 00000000..ffd9776e --- /dev/null +++ b/apps/meseeks/convex/objectStorage.private.ts @@ -0,0 +1,23 @@ +import { env, objectStoragePrefix } from 'schemas/envSchema'; +import { createS3ObjectStorageAdapter } from 'lib/reactor/runtimeAdapters'; + +export function createConfiguredObjectStorageAdapter() { + // + const endpoint = env.OBJECT_STORAGE_ENDPOINT ?? env.R2_ENDPOINT; + const bucket = env.OBJECT_STORAGE_BUCKET ?? env.R2_BUCKET; + const accessKeyId = env.OBJECT_STORAGE_ACCESS_KEY_ID ?? env.R2_ACCESS_KEY_ID; + const secretAccessKey = env.OBJECT_STORAGE_SECRET_ACCESS_KEY ?? env.R2_SECRET_ACCESS_KEY; + + if (!endpoint || !bucket || !accessKeyId || !secretAccessKey) { + throw new Error('Object storage is not configured.'); + } + + return createS3ObjectStorageAdapter({ + endpoint, + bucket, + accessKeyId, + secretAccessKey, + region: env.OBJECT_STORAGE_REGION ?? env.R2_REGION ?? 'auto', + prefix: objectStoragePrefix(), + }); +} diff --git a/apps/meseeks/convex/proOwner.private.ts b/apps/meseeks/convex/proOwner.private.ts new file mode 100644 index 00000000..8315930a --- /dev/null +++ b/apps/meseeks/convex/proOwner.private.ts @@ -0,0 +1,11 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { env } from 'schemas/envSchema'; +import type { Id } from './_generated/dataModel'; + +export function configuredProOwner(): Id<'users'> | undefined { + // + const parsed = zid('users').safeParse(env.PRO_OWNER_USER_ID); + if (!parsed.success) return undefined; + + return parsed.data; +} diff --git a/apps/meseeks/convex/reactor.private.ts b/apps/meseeks/convex/reactor.private.ts index 82272250..06b51da0 100644 --- a/apps/meseeks/convex/reactor.private.ts +++ b/apps/meseeks/convex/reactor.private.ts @@ -1,424 +1,1080 @@ +import { zid } from 'convex-helpers/server/zod3'; 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 { defineMutation, defineQuery } from 'lib/convex'; +import { NotFound } from 'lib/errors'; +import { authorSchema } from 'schemas/authorSchema'; +import { actionResultSchema, actionStatusSchema, actionWarningSchema, costSchema } from 'schemas/actionSchema'; 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}`); +import type { Doc, Id } from './_generated/dataModel'; +import type { MutationCtx } from './_generated/server'; +import { addSettlementTransaction } from './transactions.private'; - const { task, action, skill } = await ctx.runQuery(internal.reactor._prepare, { - taskId, - actionId, - }); +const interruptibleStatuses = ['pending authorization', 'enqueued', 'running'] as const; +const actionBaseSchema = z.object({ + owner: zid('users'), + file: zid('files'), + skillKey: z.string().min(1), + args: z.record(z.unknown()), + loopKey: z.string().min(1).optional(), + intelligenceKey: z.string().min(1).optional(), +}); +const recordActionResultSchema = z.object({ + result: actionResultSchema.optional(), +}); - console.debug( - `Using skill ${skill.key} with ${Object.keys(action.args).length} args: ${Object.keys(action.args).join(', ')}`, - ); +type ActionBaseInput = z.output; +type ActionResult = z.infer; +type ActionResultFile = ActionResult['files'][number]; +type ActionWarning = z.infer; +type SettledActionStatus = 'succeeded' | 'failed' | 'skipped'; - // 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); - }); +type ActionCause = + | { + kind: 'human'; + author: Id<'users'>; + } + | { + kind: 'reaction'; + previousActionId: Id<'actions'>; + } + | { + kind: 'trigger'; + triggerId: Id<'triggers'>; + } + | { + kind: 'authored'; + author: z.infer; + }; - // wrap the entire try block execution with timeout - const executeWithTimeout = async () => { +type ActionLifecycle = + | { + kind: 'enqueued'; + } + | { + kind: 'settled'; + status: SettledActionStatus; + result?: z.infer; + costs?: Array>; + settledAt?: number; + }; + +export const nextActionIndex = defineQuery({ + args: z.object({ + file: zid('files'), + }), + handler: async (ctx, { file }) => { // - // prepare context if needed - const context = skill.kind === 'soft' ? await prepareContext(ctx, task, action, skill) : undefined; + const latest = await ctx.db + .query('actions') + .withIndex('by_file_index', (q) => q.eq('file', file)) + .order('desc') + .first(); + + return latest ? latest.index + 1 : 0; + }, +}); - // persist initial action details with request context - await persistInitialActionDetails(ctx, action, skill, context); +export const enqueueHumanAction = defineMutation({ + args: actionBaseSchema.extend({ + author: zid('users'), + }), + handler: async (ctx, args) => { + // + return await insertAction(ctx, { + ...args, + cause: { + kind: 'human', + author: args.author, + }, + lifecycle: { + kind: 'enqueued', + }, + }); + }, +}); - // check budget - const expectedCost = await ensureWithinBudget(ctx, task, action, skill, context); +export const enqueueReactionAction = defineMutation({ + args: actionBaseSchema.extend({ + previousActionId: zid('actions'), + }), + handler: async (ctx, args) => { + // + return await insertAction(ctx, { + ...args, + cause: { + kind: 'reaction', + previousActionId: args.previousActionId, + }, + lifecycle: { + kind: 'enqueued', + }, + }); + }, +}); - console.debug(`Expected cost ${asDollars({ bigInt: expectedCost, precision: 6 })} energy.`); +export const enqueueTriggerAction = defineMutation({ + args: actionBaseSchema.extend({ + triggerId: zid('triggers'), + }), + handler: async (ctx, args) => { + // + return await insertAction(ctx, { + ...args, + cause: { + kind: 'trigger', + triggerId: args.triggerId, + }, + lifecycle: { + kind: 'enqueued', + }, + }); + }, +}); - // if the action is not yet authorized, try auto-approving it - if (!action.approvedAt) { - // - const wasAutoApproved = await tryAutoApprove(ctx, task, action, skill, expectedCost); +export const recordHumanAction = defineMutation({ + args: actionBaseSchema + .extend({ + author: zid('users'), + status: z.enum(['succeeded', 'failed', 'skipped']).default('succeeded'), + }) + .merge(recordActionResultSchema), + handler: async (ctx, args) => { + // + return await insertAction(ctx, { + ...args, + cause: { + kind: 'human', + author: args.author, + }, + lifecycle: { + kind: 'settled', + status: args.status, + result: args.result, + }, + }); + }, +}); - // if failed, request human approval - if (!wasAutoApproved) return await requestHumanApproval(ctx, { actionId, taskId }); - } +export const recordTriggerAction = defineMutation({ + args: actionBaseSchema + .extend({ + triggerId: zid('triggers'), + status: z.enum(['succeeded', 'failed', 'skipped']).default('succeeded'), + }) + .merge(recordActionResultSchema), + handler: async (ctx, args) => { + // + return await insertAction(ctx, { + ...args, + cause: { + kind: 'trigger', + triggerId: args.triggerId, + }, + lifecycle: { + kind: 'settled', + status: args.status, + result: args.result, + }, + }); + }, +}); - 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: [], +export const recordMutationAction = defineMutation({ + args: actionBaseSchema + .pick({ + owner: true, + file: true, + skillKey: true, + args: true, + }) + .extend({ + author: authorSchema, + }) + .merge(recordActionResultSchema), + handler: async (ctx, args) => { + // + return await insertAction(ctx, { + ...args, + cause: { + kind: 'authored', + author: args.author, + }, + lifecycle: { + kind: 'settled', + status: 'succeeded', + result: args.result, }, }); + }, +}); - let toolResult: AIToolResult | undefined; - for await (const part of execution) { - if (part.type === 'final') { - toolResult = part.output; +export const interruptFileWork = defineMutation({ + args: z.object({ + file: zid('files'), + interruptedAt: z.number(), + }), + handler: async (ctx, { file, interruptedAt }) => { + // + let count = 0; + + for (const status of interruptibleStatuses) { + const actions = await ctx.db + .query('actions') + .withIndex('by_file_status', (q) => q.eq('file', file).eq('status', status)) + .collect(); + + for (const action of actions) { + if (action.interruptedAt !== undefined) continue; + await ctx.db.patch(action._id, { interruptedAt }); + count += 1; } } - if (!toolResult) throw new Error(`Tool execution returned no result for action ${action._id}.`); + return count; + }, +}); + +export const claimAction = defineMutation({ + args: z.object({ + actionId: zid('actions'), + expectedCost: z.bigint(), + maxCost: z.bigint(), + }), + handler: async (ctx, { actionId, expectedCost, maxCost }) => { + // + const now = Date.now(); + const action = await ctx.db.get(actionId); + if (!action) throw NotFound(); + + if (isSettledStatus(action.status)) { + return { status: 'settled' as const }; + } + + if (action.status === 'running') { + return { + status: 'already-running' as const, + reservedBudget: action.reservedBudget ?? 0n, + }; + } + + if (action.status === 'pending authorization') { + return { status: 'pending-authorization' as const }; + } + + if (action.status !== 'enqueued') { + return { + status: 'not-claimable' as const, + actionStatus: action.status, + }; + } + + const causalParent = await findCausalParentAction(ctx, { action }); + if (causalParent && causalParent.settledAt === undefined) { + return { status: 'waiting-for-cause' as const }; + } + + const file = await ctx.db.get(action.file); + if (!file) throw NotFound(); + + const budget = file.budget; + if (maxCost > 0n && (!budget || budget.available < maxCost)) { + const result = { + text: 'Budget reservation failed.', + files: [], + metadata: { + kind: 'budget', + required: maxCost, + available: budget?.available ?? 0n, + }, + }; + const materialized = await materializeActionResult(ctx, { + action: { + _id: actionId, + owner: action.owner, + file: action.file, + index: action.index, + skillKey: action.skillKey, + status: 'failed', + }, + status: 'failed', + result, + warnings: undefined, + now, + }); + if (materialized.resultFile) { + await ctx.db.patch(actionId, { + status: 'failed', + expectedCost, + maxCost, + resultFile: materialized.resultFile.file, + settledAt: now, + }); + } else { + await ctx.db.patch(actionId, { + status: 'failed', + expectedCost, + maxCost, + settledAt: now, + }); + } + await recordMutationDetail(ctx, { + action: actionId, + result: materialized.result, + }); - const { result, costs } = toolResult; + return { status: 'budget-failed' as const }; + } - await setFinished(ctx, { - actionId, - taskId, - result, - status: 'succeeded', - costs, - // TODO: also persist reactions + if (budget && maxCost > 0n) { + await ctx.db.patch(file._id, { + budget: { + ...budget, + available: budget.available - maxCost, + reserved: budget.reserved + maxCost, + }, + updatedAt: now, + }); + } + + await ctx.db.patch(actionId, { + status: 'running', + expectedCost, + maxCost, + reservedBudget: maxCost, + claimedAt: now, + startedAt: now, }); - }; - try { - // - // race the entire execution against the timeout - await Promise.race([executeWithTimeout(), timeoutPromise]); - // - } catch (error) { + return { status: 'running' as const }; + }, +}); + +export const settleAction = defineMutation({ + args: z.object({ + actionId: zid('actions'), + status: z.enum(['succeeded', 'failed', 'skipped']), + result: actionResultSchema.optional(), + costs: z.array(costSchema).default([]), + warnings: z.array(actionWarningSchema).optional(), + shouldReleaseAvailableBudget: z.boolean().default(false), + }), + handler: async (ctx, { actionId, status, result, costs, warnings, shouldReleaseAvailableBudget }) => { // - const result = await handleActionError({ actionId, action, error }); - - await setFinished(ctx, { - actionId, - taskId, - status: 'failed', - costs: [], - result: result ?? { text: 'Max auto-fix attempts reached.', reactions: [] }, + const now = Date.now(); + const action = await ctx.db.get(actionId); + if (!action) throw NotFound(); + + const file = await ctx.db.get(action.file); + if (!file) throw NotFound(); + + const actualCost = costs.reduce((total, cost) => total + cost.amount, 0n); + const reservedBudget = action.reservedBudget ?? 0n; + const budget = file.budget; + const needsAttention = budget + ? settleFileBudget({ budget, reservedBudget, actualCost }).needsAttention + : actualCost > 0n; + const shouldReleaseAvailable = shouldReleaseAvailableBudget || shouldReleaseAvailableBudgetForResult(result); + let releasedAvailableBudget = 0n; + + if (budget) { + const settlement = settleFileBudget({ budget, reservedBudget, actualCost }); + const settledBudget = shouldReleaseAvailable + ? releaseAvailableBudget(settlement.budget) + : settlement.budget; + releasedAvailableBudget = settlement.budget.available - settledBudget.available; + + await ctx.db.patch(file._id, { + budget: settledBudget, + updatedAt: now, + }); + } + + if (actualCost > 0n) { + await addSettlementTransaction(ctx, { + owner: action.owner, + file: action.file, + action: actionId, + value: { + symbol: 'USD', + amount: -actualCost, + }, + description: `${action.skillKey} settlement`, + }); + if (budget) { + await releaseCommittedBudget(ctx, { + owner: action.owner, + amount: actualCost, + }); + } + } + if (releasedAvailableBudget > 0n) { + await releaseCommittedBudget(ctx, { + owner: action.owner, + amount: releasedAvailableBudget, + }); + } + + const detailResult = withAttentionMetadata(result, needsAttention); + const materialized = await materializeActionResult(ctx, { + action: { + _id: actionId, + owner: action.owner, + file: action.file, + index: action.index, + skillKey: action.skillKey, + status, + }, + status, + result: detailResult, + warnings, + now, }); + if (materialized.resultFile) { + await ctx.db.patch(actionId, { + status, + resultFile: materialized.resultFile.file, + costs, + settledAt: now, + }); + } else { + await ctx.db.patch(actionId, { + status, + costs, + settledAt: now, + }); + } + + if (materialized.result || warnings) { + await recordMutationDetail(ctx, { + action: actionId, + result: materialized.result, + costs, + warnings, + }); + } + + return { needsAttention }; + }, +}); + +export const latestActionForFile = defineQuery({ + args: z.object({ + file: zid('files'), + }), + handler: async (ctx, { file }) => { // - } finally { + return await ctx.db + .query('actions') + .withIndex('by_file_index', (q) => q.eq('file', file)) + .order('desc') + .first(); + }, +}); + +export const recentActionsForFile = defineQuery({ + args: z.object({ + file: zid('files'), + limit: z.number().int().positive().max(100).default(20), + }), + handler: async (ctx, { file, limit }) => { // - if (timeoutId) clearTimeout(timeoutId); - await runNextActionIfNeeded(ctx, { taskId }); + return await ctx.db + .query('actions') + .withIndex('by_file_index', (q) => q.eq('file', file)) + .order('desc') + .take(limit); + }, +}); + +async function insertAction( + ctx: MutationCtx, + args: ActionBaseInput & { + cause: ActionCause; + lifecycle: ActionLifecycle; + }, +) { + // + const now = Date.now(); + const index = await nextActionIndex(ctx, { file: args.file }); + const causal = await deriveActionLineage(ctx, { + owner: args.owner, + file: args.file, + loopKey: args.loopKey, + cause: args.cause, + }); + const status = args.lifecycle.kind === 'enqueued' ? 'enqueued' : args.lifecycle.status; + const result = args.lifecycle.kind === 'enqueued' ? undefined : args.lifecycle.result; + const costs = args.lifecycle.kind === 'enqueued' ? [] : (args.lifecycle.costs ?? []); + const settledAt = args.lifecycle.kind === 'enqueued' ? undefined : (args.lifecycle.settledAt ?? Date.now()); + + const actionId = await ctx.db.insert('actions', { + owner: args.owner, + file: args.file, + index, + depth: causal.depth, + author: causal.author, + skillKey: args.skillKey, + loopKey: args.loopKey, + intelligenceKey: args.intelligenceKey, + args: args.args, + status, + costs, + settledAt, + createdAt: now, + }); + + const materialized = + args.lifecycle.kind === 'enqueued' + ? { result: undefined, resultFile: undefined } + : await materializeActionResult(ctx, { + action: { + _id: actionId, + owner: args.owner, + file: args.file, + index, + skillKey: args.skillKey, + status: args.lifecycle.status, + }, + status: args.lifecycle.status, + result, + warnings: undefined, + now, + }); + + if (materialized.resultFile) { + await ctx.db.patch(actionId, { + spark: causal.spark ?? actionId, + resultFile: materialized.resultFile.file, + }); + } else { + await ctx.db.patch(actionId, { spark: causal.spark ?? actionId }); } + if (materialized.result) await recordMutationDetail(ctx, { action: actionId, result: materialized.result, costs }); + + return actionId; } -function parseArgs(tool: ReturnType, args: unknown) { +async function deriveActionLineage( + ctx: MutationCtx, + args: { + owner: Id<'users'>; + file: Id<'files'>; + loopKey?: string; + cause: ActionCause; + }, +): Promise<{ + author: z.infer; + depth: number; + spark: Id<'actions'> | undefined; +}> { // - // all our tools use Zod schemas, so inputSchema always has safeParse - const schema = tool.inputSchema; + if (args.cause.kind === 'human') { + return { + author: args.cause.author, + depth: 0, + spark: undefined, + }; + } + + if (args.cause.kind === 'reaction') { + const previous = await ctx.db.get(args.cause.previousActionId); + if (!previous) throw NotFound(); + if (previous.owner !== args.owner || previous.file !== args.file) throw NotFound(); - // 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'); + return { + author: args.cause.previousActionId, + depth: previous.depth + 1, + spark: previous.spark ?? previous._id, + }; } - const parsedArgs = schema.safeParse(args); + if (args.cause.kind === 'trigger') { + await ensureTriggerCanAuthorAction(ctx, { + owner: args.owner, + file: args.file, + loopKey: args.loopKey, + triggerId: args.cause.triggerId, + }); - if (!parsedArgs.success) throw new Error(`Invalid skill args: ${parsedArgs.error.message}`); + return { + author: args.cause.triggerId, + depth: 0, + spark: undefined, + }; + } - return parsedArgs.data; -} + const userId = zid('users').safeParse(args.cause.author); + if (userId.success) { + const user = await ctx.db.get(userId.data); + if (user) { + if (user._id !== args.owner) throw NotFound(); -async function estimateAndPersistCost( - ctx: ActionCtx, - action: Doc<'actions'>, - task: Doc<'tasks'>, - skill: z.infer, - context?: MagicRockContext, -) { - if (action.estimatedCost) return action.estimatedCost; + return { + author: args.cause.author, + depth: 0, + spark: undefined, + }; + } + } - const estimatedCost = estimateCostFor(skill, task, action._id, context); + const parentActionId = zid('actions').safeParse(args.cause.author); + if (parentActionId.success) { + const previous = await ctx.db.get(parentActionId.data); + if (!previous) throw NotFound(); + if (previous.owner !== args.owner || previous.file !== args.file) throw NotFound(); - console.debug( - `Setting estimated cost for ${action._id}: ${asDollars({ bigInt: estimatedCost, precision: 6 })} energy`, - ); + return { + author: args.cause.author, + depth: previous.depth + 1, + spark: previous.spark ?? previous._id, + }; + } - await ctx.runMutation(internal.reactor._setEstimatedCost, { - actionId: action._id, - estimatedCost, - }); + const triggerId = zid('triggers').safeParse(args.cause.author); + if (triggerId.success) { + await ensureTriggerCanAuthorAction(ctx, { + owner: args.owner, + file: args.file, + loopKey: args.loopKey, + triggerId: triggerId.data, + }); - return estimatedCost; + return { + author: args.cause.author, + depth: 0, + spark: undefined, + }; + } + + throw NotFound(); } -async function ensureWithinBudget( - ctx: ActionCtx, - task: Doc<'tasks'>, - action: Doc<'actions'>, - skill: z.infer, - context?: MagicRockContext, +async function ensureTriggerCanAuthorAction( + ctx: MutationCtx, + args: { + owner: Id<'users'>; + file: Id<'files'>; + loopKey?: string; + triggerId: Id<'triggers'>; + }, ) { // - 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, - ); + const trigger = await ctx.db.get(args.triggerId); + if (!trigger) throw NotFound(); + + if (trigger.kind === 'file') { + if (trigger.owner !== args.owner || trigger.file !== args.file) throw NotFound(); + return; } - return estimatedCost; + const loop = await ctx.db.get(trigger.loop); + if (!loop || loop.owner !== trigger.owner) throw NotFound(); + if (trigger.owner === args.owner) return; + if (loop.isPublic === true && loop.key === args.loopKey) return; + + throw NotFound(); } -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, - }); +async function findCausalParentAction( + ctx: MutationCtx, + args: { + action: Doc<'actions'>; + }, +): Promise | null | undefined> { + // + const parsedAuthor = zid('actions').safeParse(args.action.author); + if (!parsedAuthor.success) return undefined; - return true; + const candidate = await ctx.db.get(parsedAuthor.data); + if (!candidate) return undefined; + if (candidate.owner !== args.action.owner) return undefined; + if (candidate.file !== args.action.file) return undefined; + if (typeof candidate.index !== 'number') return undefined; + if (typeof candidate.skillKey !== 'string') return undefined; + if (!isActionStatus(candidate.status)) return undefined; + + return candidate; } -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); +function isSettledStatus(status: Doc<'actions'>['status']) { + return status === 'succeeded' || status === 'failed' || status === 'skipped'; +} - // reject if requires more budget - if (skill.preApprovedCost === 'none') return false; - if (skill.preApprovedCost < expectedCost) return false; +function isActionStatus(status: unknown): status is Doc<'actions'>['status'] { + // + return actionStatusSchema.safeParse(status).success; +} - // 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.`, - ); +function settleFileBudget(args: { + budget: { + total: bigint; + available: bigint; + reserved: bigint; + spent: bigint; + }; + reservedBudget: bigint; + actualCost: bigint; +}) { + // + const { budget, reservedBudget, actualCost } = args; + const reservedAfterRelease = budget.reserved >= reservedBudget ? budget.reserved - reservedBudget : 0n; + const unusedReservation = reservedBudget > actualCost ? reservedBudget - actualCost : 0n; + const overrun = actualCost > reservedBudget ? actualCost - reservedBudget : 0n; + const availableWithRefund = budget.available + unusedReservation; + const availableAfterOverrun = availableWithRefund >= overrun ? availableWithRefund - overrun : 0n; - return false; - } + return { + budget: { + total: budget.total, + available: availableAfterOverrun, + reserved: reservedAfterRelease, + spent: budget.spent + actualCost, + }, + needsAttention: overrun > availableWithRefund, + }; +} + +function releaseAvailableBudget(budget: { total: bigint; available: bigint; reserved: bigint; spent: bigint }) { + // + return { + ...budget, + total: budget.total > budget.available ? budget.total - budget.available : 0n, + available: 0n, + }; +} + +function shouldReleaseAvailableBudgetForResult(result: z.infer | undefined) { + // + return result?.metadata?.['kind'] === 'seek' && result.metadata['seekState'] === 'done'; +} + +function withAttentionMetadata(result: z.infer | undefined, needsAttention: boolean) { + // + if (!needsAttention) return result; - return autoApprove(ctx, task, action); + return { + text: result?.text, + files: result?.files ?? [], + metadata: { + ...(result?.metadata ?? {}), + needsAttention, + }, + }; } -// ¡¡¡do not remove — this prevents machines from taking over!!! -async function hasReachedMaxConsecutiveCompanionActions( - ctx: ActionCtx, // - task: Doc<'tasks'>, +async function materializeActionResult( + ctx: MutationCtx, + args: { + action: { + _id: Id<'actions'>; + owner: Id<'users'>; + file: Id<'files'>; + index: number; + skillKey: string; + status: SettledActionStatus; + }; + status: SettledActionStatus; + result?: ActionResult; + warnings?: ActionWarning[]; + now: number; + }, ) { // - const lastActions = await ctx.runQuery(internal.action._findLastActions, { - taskId: task._id, - amount: env.MAX_CONSECUTIVE_COMPANION_ACTIONS, + const content = renderActionResultContent({ + action: args.action, + status: args.status, + result: args.result, + warnings: args.warnings, + }); + const resultFile = await upsertActionResultFile(ctx, { + action: args.action, + content, + now: args.now, }); - return lastActions.every((action: Doc<'actions'>) => action.author !== task.owner); + return { + result: resultWithFile({ + result: args.result, + resultFile, + fallbackText: fallbackResultText({ + status: args.status, + warnings: args.warnings, + }), + }), + resultFile, + }; } -async function requestHumanApproval( - ctx: ActionCtx, // - { - actionId, - taskId, - }: { - actionId: Id<'actions'>; - taskId: Id<'tasks'>; +async function upsertActionResultFile( + ctx: MutationCtx, + args: { + action: { + _id: Id<'actions'>; + owner: Id<'users'>; + file: Id<'files'>; + index: number; + }; + content: string; + now: number; }, -) { +): Promise { // - await ctx.runMutation(internal.reactor._requestAuthorization, { actionId, taskId }); + const actionsDirectory = await findOrCreateActionResultChild(ctx, { + owner: args.action.owner, + parent: args.action.file, + name: 'actions', + author: args.action._id, + now: args.now, + }); + const actionDirectoryName = String(args.action.index).padStart(6, '0'); + const actionDirectory = await findOrCreateActionResultChild(ctx, { + owner: args.action.owner, + parent: actionsDirectory, + name: actionDirectoryName, + author: args.action._id, + now: args.now, + }); + const resultFile = await findOrCreateActionResultChild(ctx, { + owner: args.action.owner, + parent: actionDirectory, + name: 'result.mdx', + author: args.action._id, + now: args.now, + }); + const fitted = fitInlineResultContent(args.content); + const contentId = await ctx.db.insert('file_contents', { + owner: args.action.owner, + file: resultFile, + author: args.action._id, + text: fitted.text, + createdAt: args.now, + }); + + await ctx.db.patch(resultFile, { + currentContent: { + kind: 'text', + content: contentId, + }, + updatedAt: args.now, + }); + + return { + file: resultFile, + path: `actions/${actionDirectoryName}/result.mdx`, + size: fitted.size, + contentType: 'text/mdx', + }; } -async function handleActionError({ - actionId, - action, - error, -}: { - actionId: Id<'actions'>; - action: Doc<'actions'>; - error: unknown; -}): Promise<{ - text: string; - reactions: Array>; -} | null> { +async function findOrCreateActionResultChild( + ctx: MutationCtx, + args: { + owner: Id<'users'>; + parent: Id<'files'>; + name: string; + author: Id<'actions'>; + now: number; + }, +) { // - console.info(`action ${actionId} execution failed: ${error}`); - - const result: { - text: string; - reactions: Array>; - } = { - text: messageFrom(error), - reactions: [], + 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) return existing._id; + + return await ctx.db.insert('files', { + owner: args.owner, + parent: args.parent, + name: args.name, + author: args.author, + createdAt: args.now, + updatedAt: args.now, + }); +} + +function renderActionResultContent(args: { + action: { + index: number; + skillKey: string; }; + status: SettledActionStatus; + result?: ActionResult; + warnings?: ActionWarning[]; +}) { + // + const parts = [`# ${args.action.skillKey}()`, `Action ${args.action.index}`, `Status: ${args.status}`]; - // 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, - }, - }, - ]); - // + if (args.result?.text) { + parts.push(args.result.text); } else { - // - // attempt auto-fix - result.reactions = createReactions(action, [ - { - skillKey: 'iterate', - args: {}, - }, - ]); + parts.push(fallbackResultText({ status: args.status, warnings: args.warnings })); + } + + if (args.warnings && args.warnings.length > 0) { + parts.push(renderWarnings(args.warnings)); + } + + if (args.result?.files.length) { + parts.push(renderResultFiles(args.result.files)); } - return result; + return parts.join('\n\n').trimEnd() + '\n'; } -function isNotEnoughBudgetError(error: unknown): error is ReturnType { +function renderWarnings(warnings: ActionWarning[]) { // - return isError(NOT_ENOUGH_BUDGET_ERROR, error); + const lines = ['## Warnings']; + for (const warning of warnings) { + lines.push(`- ${warning.severity}: ${warning.message}`); + } + + return lines.join('\n'); } -async function setFinished( - ctx: ActionCtx, - args: { - actionId: Id<'actions'>; - taskId: Id<'tasks'>; - result: { - text?: string | undefined; - reactions: Array>; +function renderResultFiles(files: ActionResultFile[]) { + // + const lines = ['## Files']; + for (const file of files) { + lines.push(`- ${file.path} (${file.file})`); + } + + return lines.join('\n'); +} + +function fallbackResultText(args: { status: SettledActionStatus; warnings?: ActionWarning[] }) { + // + if (args.warnings && args.warnings.length > 0) return args.warnings[0].message; + + return `Action ${args.status}.`; +} + +function resultWithFile(args: { + result?: ActionResult; + resultFile: ActionResultFile; + fallbackText: string; +}): ActionResult { + // + const files = [args.resultFile].concat( + (args.result?.files ?? []).filter((file) => file.file !== args.resultFile.file), + ); + const text = args.result?.text ?? args.fallbackText; + + if (args.result?.metadata !== undefined) { + return { + text, + files, + metadata: args.result.metadata, }; - status: 'succeeded' | 'failed'; - costs: Array<{ - symbol: z.infer; - amount: bigint; - description: string; - }>; - }, -) { - return await ctx.runMutation(internal.reactor._finish, args); + } + + return { + text, + files, + }; } -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'); - } +function fitInlineResultContent(text: string) { + // + const encoder = new TextEncoder(); + const original = encoder.encode(text); + if (original.byteLength <= env.MAX_REACTOR_INLINE_CONTENT_BYTES) { + return { + text, + size: original.byteLength, + }; + } - 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); + const notice = '\n\nResult truncated because it exceeded the inline result file limit.\n'; + let fittedText = text; + while ( + encoder.encode(fittedText + notice).byteLength > env.MAX_REACTOR_INLINE_CONTENT_BYTES && + fittedText.length > 0 + ) { + fittedText = fittedText.slice(0, Math.floor(fittedText.length * 0.8)); } + const truncated = fittedText + notice; + + return { + text: truncated, + size: encoder.encode(truncated).byteLength, + }; +} + +function canonicalResultFile(result: z.infer | undefined) { + // + return result?.files[0]?.file; } -export async function runNextActionIfNeeded( - ctx: ActionCtx | MutationCtx, // - { taskId }: { taskId: Id<'tasks'> }, +async function recordMutationDetail( + ctx: MutationCtx, + args: { + action: Id<'actions'>; + result?: z.infer; + costs?: Array>; + warnings?: Array>; + }, ) { - return await ctx.runMutation(internal.reactor._claimAndScheduleNext, { taskId }); + // + const now = Date.now(); + const kind = z.literal('mutation').parse('mutation'); + const existing = await ctx.db + .query('details') + .withIndex('by_action', (q) => q.eq('action', args.action)) + .first(); + if (existing?.kind === 'model') { + await ctx.db.patch(existing._id, { + metadata: args.result?.metadata, + costs: args.costs ?? existing.costs, + warnings: args.warnings, + updatedAt: now, + }); + return existing._id; + } + if (existing?.kind === 'request' || existing?.kind === 'execution') { + await ctx.db.patch(existing._id, { + costs: args.costs ?? existing.costs, + warnings: args.warnings, + updatedAt: now, + }); + return existing._id; + } + if (existing && existing.kind !== 'mutation') { + await ctx.db.delete(existing._id); + } + + const value = { + kind, + action: args.action, + summary: args.result?.text ?? args.warnings?.[0]?.message ?? 'Action settled.', + resultFile: canonicalResultFile(args.result), + metadata: args.result?.metadata, + warnings: args.warnings, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }; + + if (existing?.kind === 'mutation') { + await ctx.db.patch(existing._id, value); + return existing._id; + } + + return await ctx.db.insert('details', value); +} + +async function releaseCommittedBudget(ctx: MutationCtx, args: { owner: Id<'users'>; amount: bigint }) { + // + const user = await ctx.db.get(args.owner); + if (!user) throw NotFound(); + + const committed = user.committedBudgetUSD ?? 0n; + await ctx.db.patch(args.owner, { + committedBudgetUSD: committed > args.amount ? committed - args.amount : 0n, + }); } diff --git a/apps/meseeks/convex/reactor.ts b/apps/meseeks/convex/reactor.ts deleted file mode 100644 index 0edbf2ae..00000000 --- a/apps/meseeks/convex/reactor.ts +++ /dev/null @@ -1,251 +0,0 @@ -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'; - -// 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({ - args: { - taskId: zid('tasks'), - actionId: zid('actions'), - }, - handler: async ( - ctx, - { taskId, actionId }, - ): Promise<{ - task: Doc<'tasks'>; - action: Doc<'actions'>; - skill: Doc<'skills'> | z.infer; - }> => { - // - const [task, action] = await Promise.all([ - findTask(ctx, { taskId }), // - findAction(ctx, { actionId }), - ]); - - const skill = await findSkill(ctx, { - key: action.skillKey, - owner: task.owner, - }); - - 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, - }); - 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({ - args: { - actionId: zid('actions'), - estimatedCost: z.bigint(), - }, - handler: async (ctx, { actionId, estimatedCost }) => { - return await ctx.db.patch(actionId, { estimatedCost }); - }, -}); - -// 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 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, - })), - }); - } - }, -}); - -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(' '), - }; -} diff --git a/apps/meseeks/convex/reads.private.ts b/apps/meseeks/convex/reads.private.ts new file mode 100644 index 00000000..73075051 --- /dev/null +++ b/apps/meseeks/convex/reads.private.ts @@ -0,0 +1,77 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import { defineMutation, defineQuery } from 'lib/convex'; +import type { Id } from './_generated/dataModel'; +import type { MutationCtx, QueryCtx } from './_generated/server'; +import { latestActionForFile } from './reactor.private'; + +export const findReadCursor = defineQuery({ + args: z.object({ + user: zid('users'), + file: zid('files'), + }), + handler: async (ctx, { user, file }) => { + // + return await ctx.db + .query('reads') + .withIndex('by_user_file', (q) => + q + .eq('user', user) // + .eq('file', file), + ) + .unique(); + }, +}); + +export const markFileRead = defineMutation({ + args: z.object({ + user: zid('users'), + file: zid('files'), + lastReadActionIndex: z.number().int().nonnegative(), + }), + handler: async (ctx, { user, file, lastReadActionIndex }) => { + // + const now = Date.now(); + const existing = await findReadCursor(ctx, { user, file }); + + if (existing) { + await ctx.db.patch(existing._id, { + lastReadActionIndex, + lastReadAt: now, + }); + return existing._id; + } + + return await ctx.db.insert('reads', { + user, + file, + lastReadActionIndex, + lastReadAt: now, + }); + }, +}); + +export async function deriveReadState( + ctx: MutationCtx | QueryCtx, + args: { + user: Id<'users'>; + file: Id<'files'>; + }, +) { + // + const latestAction = await latestActionForFile(ctx, { file: args.file }); + const cursor = await findReadCursor(ctx, { + user: args.user, + file: args.file, + }); + const latestActionIndex = latestAction?.index ?? -1; + const lastReadActionIndex = cursor?.lastReadActionIndex ?? -1; + + return { + cursor, + latestActionIndex, + lastReadActionIndex, + isUnread: latestActionIndex > lastReadActionIndex, + }; +} +// diff --git a/apps/meseeks/convex/reads.ts b/apps/meseeks/convex/reads.ts new file mode 100644 index 00000000..63952b39 --- /dev/null +++ b/apps/meseeks/convex/reads.ts @@ -0,0 +1,46 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { query, mutation } from 'lib/convex'; +import { ensureFileOwner } from './files.private'; +import { deriveReadState, markFileRead } from './reads.private'; +import { latestActionForFile } from './reactor.private'; +import { getCurrentUser } from './users.private'; + +export const state = query({ + args: { + file: zid('files'), + }, + handler: async (ctx, { file }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + await ensureFileOwner(ctx, { + fileId: file, + owner: currentUser._id, + }); + + return await deriveReadState(ctx, { + user: currentUser._id, + file, + }); + }, +}); + +export const markRead = mutation({ + args: { + file: zid('files'), + }, + handler: async (ctx, { file }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + await ensureFileOwner(ctx, { + fileId: file, + owner: currentUser._id, + }); + const latestAction = await latestActionForFile(ctx, { file }); + + return await markFileRead(ctx, { + user: currentUser._id, + file, + lastReadActionIndex: latestAction?.index ?? 0, + }); + }, +}); diff --git a/apps/meseeks/convex/routes.private.ts b/apps/meseeks/convex/routes.private.ts new file mode 100644 index 00000000..150100f9 --- /dev/null +++ b/apps/meseeks/convex/routes.private.ts @@ -0,0 +1,334 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import { defineMutation, defineQuery } from 'lib/convex'; +import { isError, NOT_FOUND_ERROR } from 'lib/errors'; +import { managedComponents, managedRoutes } from 'lib/proDefinitions'; +import { authorSchema } from 'schemas/authorSchema'; +import type { Doc, Id } from './_generated/dataModel'; +import type { MutationCtx, QueryCtx } from './_generated/server'; +import { + catFile, + createFile, + ensureFileOwner, + ensureFileVisible, + findChildByName, + writeFileContent, +} from './files.private'; +import { configuredProOwner } from './proOwner.private'; +import { recordMutationAction } from './reactor.private'; + +const obsoleteManagedRouteSlugs = ['/list']; + +export const upsertRoute = defineMutation({ + args: z.object({ + owner: zid('users'), + slug: z.string().min(1), + file: zid('files'), + defaultFile: zid('files').optional(), + isPublic: z.boolean().optional(), + sourceOwner: zid('users').optional(), + sourceKey: z.string().min(1).optional(), + sourceFile: zid('files').optional(), + author: authorSchema, + }), + handler: async (ctx, { owner, slug, file, defaultFile, isPublic, sourceOwner, sourceKey, sourceFile, author }) => { + // + await ensureFileVisible(ctx, { + fileId: file, + viewer: owner, + }); + if (defaultFile) { + await ensureFileOwner(ctx, { + fileId: defaultFile, + owner, + }); + } + + const existing = await ctx.db + .query('routes') + .withIndex('by_owner_slug', (q) => q.eq('owner', owner).eq('slug', slug)) + .unique(); + const now = Date.now(); + + if (existing) { + if ( + existing.file === file && + existing.defaultFile === defaultFile && + existing.isPublic === isPublic && + existing.sourceOwner === sourceOwner && + existing.sourceKey === sourceKey && + existing.sourceFile === sourceFile + ) { + return existing._id; + } + + await ctx.db.patch(existing._id, { + file, + defaultFile, + isPublic, + sourceOwner, + sourceKey, + sourceFile, + author, + updatedAt: now, + }); + + await recordMutationAction(ctx, { + owner, + file: defaultFile ?? file, + author, + skillKey: 'updateRoute', + args: { + slug, + file, + defaultFile, + }, + result: { + text: `Updated route ${slug}.`, + files: [ + { + file, + path: slug, + }, + ], + }, + }); + + return existing._id; + } + + const routeId = await ctx.db.insert('routes', { + owner, + slug, + file, + defaultFile, + isPublic, + sourceOwner, + sourceKey, + sourceFile, + author, + createdAt: now, + updatedAt: now, + }); + + await recordMutationAction(ctx, { + owner, + file: defaultFile ?? file, + author, + skillKey: 'createRoute', + args: { + slug, + file, + defaultFile, + }, + result: { + text: `Created route ${slug}.`, + files: [ + { + file, + path: slug, + }, + ], + }, + }); + + return routeId; + }, +}); + +export const findRouteBySlug = defineQuery({ + args: z.object({ + owner: zid('users'), + slug: z.string().min(1), + }), + handler: async (ctx, { owner, slug }) => { + // + const owned = await ctx.db + .query('routes') + .withIndex('by_owner_slug', (q) => q.eq('owner', owner).eq('slug', slug)) + .unique(); + const visibleOwned = await visibleRoute(ctx, { owner, route: owned }); + if (visibleOwned) return visibleOwned; + + return await visibleRoute(ctx, { + owner, + route: await publicProRouteBySlug(ctx, { owner, slug }), + }); + }, +}); + +export const seedManagedRoutes = defineMutation({ + args: z.object({ + owner: zid('users'), + author: authorSchema, + defaultFile: zid('files').optional(), + }), + handler: async (ctx, { owner, author, defaultFile }) => { + // + const routeIds = []; + const proOwner = configuredProOwner(); + const shouldManageSharedComponents = !defaultFile || owner === proOwner; + const shouldWriteComponentFiles = shouldManageSharedComponents || !proOwner; + + for (const route of managedRoutes) { + const componentSeed = managedComponents.find((component) => component.key === route.componentKey); + if (!componentSeed) continue; + + const component = shouldWriteComponentFiles + ? await upsertManagedComponentFile(ctx, { + owner, + author, + seed: componentSeed, + isPublic: shouldManageSharedComponents, + }) + : await publicProRouteComponent(ctx, { + owner, + slug: route.slug, + }); + if (!component) continue; + + const routeId = await upsertRoute(ctx, { + owner, + slug: route.slug, + file: component, + defaultFile, + isPublic: shouldManageSharedComponents ? true : undefined, + sourceOwner: !shouldManageSharedComponents && proOwner ? proOwner : undefined, + sourceKey: !shouldManageSharedComponents && proOwner ? route.slug : undefined, + sourceFile: !shouldManageSharedComponents && proOwner ? component : undefined, + author, + }); + + routeIds.push(routeId); + } + + await deleteObsoleteManagedRoutes(ctx, { owner, author }); + + return routeIds; + }, +}); + +async function deleteObsoleteManagedRoutes( + ctx: MutationCtx, + args: { + owner: Id<'users'>; + author: z.infer; + }, +) { + // + for (const slug of obsoleteManagedRouteSlugs) { + const existing = await ctx.db + .query('routes') + .withIndex('by_owner_slug', (q) => q.eq('owner', args.owner).eq('slug', slug)) + .unique(); + if (!existing) continue; + + await ctx.db.delete(existing._id); + await recordMutationAction(ctx, { + owner: args.owner, + file: existing.defaultFile ?? existing.file, + author: args.author, + skillKey: 'deleteRoute', + args: { + slug, + }, + result: { + text: `Deleted route ${slug}.`, + files: [], + }, + }); + } +} + +async function upsertManagedComponentFile( + ctx: MutationCtx, + args: { + owner: Id<'users'>; + author: z.infer; + seed: (typeof managedComponents)[number]; + isPublic: boolean; + }, +) { + // + const existingComponent = await findChildByName(ctx, { + owner: args.owner, + name: args.seed.name, + }); + const component = existingComponent + ? existingComponent._id + : await createFile(ctx, { + owner: args.owner, + name: args.seed.name, + author: args.author, + content: args.seed.body, + isPublic: args.isPublic, + tags: [ + { key: 'kind', value: 'component' }, + { key: 'key', value: args.seed.key }, + ], + shouldAddInboxTag: false, + }); + + if (!existingComponent) return component; + + if (existingComponent.isPublic !== args.isPublic) { + await ctx.db.patch(existingComponent._id, { + isPublic: args.isPublic, + updatedAt: Date.now(), + }); + } + const current = await catFile(ctx, { owner: args.owner, fileId: component }); + if (current !== args.seed.body) { + await writeFileContent(ctx, { + owner: args.owner, + fileId: component, + author: args.author, + content: args.seed.body, + }); + } + + return component; +} + +async function publicProRouteComponent(ctx: QueryCtx, args: { owner: Id<'users'>; slug: string }) { + // + const route = await publicProRouteBySlug(ctx, args); + return route?.file; +} + +async function visibleRoute( + ctx: QueryCtx, + args: { + owner: Id<'users'>; + route?: Doc<'routes'> | null; + }, +) { + // + if (!args.route) return undefined; + + try { + await ensureFileVisible(ctx, { + fileId: args.route.file, + viewer: args.owner, + }); + return args.route; + } catch (error: unknown) { + // route rows can outlive disposable preview files after schema resets; treat that as unconfigured. + if (isError(NOT_FOUND_ERROR, error)) return undefined; + + throw error; + } +} + +async function publicProRouteBySlug(ctx: QueryCtx, args: { owner: Id<'users'>; slug: string }) { + // + const routes = await ctx.db + .query('routes') + .withIndex('by_public_slug', (q) => q.eq('isPublic', true).eq('slug', args.slug)) + .collect(); + const sorted = routes + .slice() + .sort((left, right) => right.updatedAt - left.updatedAt || right._creationTime - left._creationTime); + + return sorted.find((route) => route.owner !== args.owner); +} diff --git a/apps/meseeks/convex/routes.ts b/apps/meseeks/convex/routes.ts new file mode 100644 index 00000000..575f0266 --- /dev/null +++ b/apps/meseeks/convex/routes.ts @@ -0,0 +1,15 @@ +import { z } from 'zod/v3'; +import { query } from 'lib/convex'; +import { findRouteBySlug } from './routes.private'; +import { getCurrentUser } from './users.private'; + +export const findBySlug = query({ + args: { + slug: z.string().min(1), + }, + handler: async (ctx, { slug }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + return await findRouteBySlug(ctx, { owner: currentUser._id, slug }); + }, +}); diff --git a/apps/meseeks/convex/runtime.ts b/apps/meseeks/convex/runtime.ts new file mode 100644 index 00000000..1af5256e --- /dev/null +++ b/apps/meseeks/convex/runtime.ts @@ -0,0 +1,2048 @@ +'use node'; + +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import { action, internalAction } from 'lib/convex'; +import { + createDaytonaReactorSandbox, + daytonaOutputSchema, + daytonaSettingsSchema, + materializeDaytonaWorkspaceText, +} from 'lib/daytonaSandbox'; +import { Unauthorized } from 'lib/errors'; +import { estimateIntelligenceCost, referenceIntelligenceSelection } from 'lib/proDefinitions'; +import type { IntelligenceRunInput } from 'lib/reactor/adapters'; +import { createStatelessIntelligenceAdapter } from 'lib/reactor/runtimeAdapters'; +import { actionResultFileSchema, actionResultSchema, actionWarningSchema, costSchema } from 'schemas/actionSchema'; +import { env } from 'schemas/envSchema'; +import { fileBudgetSchema, objectContentPointerSchema } from 'schemas/fileSchema'; +import { internal } from './_generated/api'; +import type { Doc } from './_generated/dataModel'; +import type { ActionCtx } from './_generated/server'; +import { createConfiguredObjectStorageAdapter } from './objectStorage.private'; + +const settledActionStatusSchema = z.enum(['succeeded', 'failed', 'skipped']); +const claimResultSchema = z.union([ + z.object({ status: z.literal('running') }), + z.object({ status: z.literal('settled') }), + z.object({ status: z.literal('already-running'), reservedBudget: z.bigint() }), + z.object({ status: z.literal('pending-authorization') }), + z.object({ status: z.literal('not-claimable'), actionStatus: z.string() }), + z.object({ status: z.literal('waiting-for-cause') }), + z.object({ status: z.literal('budget-failed') }), +]); +const managedThinkSkillNames = ['think', 'plan', 'iterate']; +const trustedInstinctSkillNames: string[] = []; + +const openAiResponseSchema = z.object({ + output_text: z.string().optional(), + output: z.array(z.record(z.unknown())).default([]), + status: z.string().optional(), + incomplete_details: z.record(z.unknown()).nullable().optional(), + usage: z.record(z.unknown()).optional(), +}); + +const openAiUsageSchema = z + .object({ + input_tokens: z.number().int().nonnegative().default(0), + output_tokens: z.number().int().nonnegative().default(0), + }) + .passthrough(); + +const openAiMessageSchema = z + .object({ + type: z.literal('message'), + content: z + .array( + z + .object({ + type: z.literal('output_text'), + text: z.string(), + }) + .passthrough(), + ) + .default([]), + }) + .passthrough(); + +const openAiReasoningSchema = z + .object({ + type: z.literal('reasoning'), + summary: z + .array( + z + .object({ + text: z.string(), + }) + .passthrough(), + ) + .default([]), + }) + .passthrough(); + +const intelligenceProviderSchema = z.object({ + provider: z.enum(['deepseek', 'moonshot', 'openai']), + intelligence: z.string().min(1), +}); +const runtimeIntelligenceSelectionSchema = z + .object({ + intelligence: z.string().min(1), + provider: intelligenceProviderSchema, + deprecatedAt: z.number().optional(), + deactivatedAt: z.number().optional(), + }) + .passthrough(); + +const chatCompletionUsageSchema = z + .object({ + prompt_tokens: z.number().int().nonnegative().default(0), + completion_tokens: z.number().int().nonnegative().default(0), + }) + .passthrough(); + +const chatCompletionChoiceSchema = z + .object({ + index: z.number().int().nonnegative(), + finish_reason: z.string().nullable().optional(), + message: z + .object({ + content: z.string().nullable().optional(), + }) + .passthrough(), + }) + .passthrough(); + +const chatCompletionResponseSchema = z + .object({ + id: z.string().optional(), + created: z.number().optional(), + model: z.string().optional(), + object: z.string().optional(), + choices: z.array(chatCompletionChoiceSchema).default([]), + usage: z.record(z.unknown()).optional(), + }) + .passthrough(); + +const inputTextContentSchema = z + .object({ + type: z.literal('input_text'), + text: z.string(), + }) + .passthrough(); + +const sandboxPathSchema = z + .string() + .min(1) + .transform((path, ctx) => { + const normalized = normalizeSandboxPath(path); + if (!normalized) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Sandbox paths must stay under /workspace and avoid parent traversal.', + }); + return z.NEVER; + } + + return normalized; + }); + +const sandboxInlineFileSchema = z.object({ + path: sandboxPathSchema, + content: z.string(), +}); + +const sandboxOptionsSchema = z + .object({ + command: z.string().min(1).optional(), + env: z.record(z.string()).default({}), + timeoutMs: z.number().int().min(1_000).max(540_000).optional(), + files: z.array(sandboxInlineFileSchema).default([]), + outputs: z.array(daytonaOutputSchema.extend({ path: sandboxPathSchema })).default([]), + }) + .passthrough(); +const executeLanguageSchema = z.enum(['javascript', 'python']); +const executeCodeSchema = z.object({ + code: z.string().min(1), + language: executeLanguageSchema.default('javascript'), +}); + +const recordedSandboxOutputsSchema = z.object({ + files: z.array(actionResultFileSchema), +}); + +const seekStateSchema = z.enum(['continue', 'done', 'blocked']); +const planTagSchema = z.object({ + key: z.string().min(1), + value: z.string(), +}); +const planMutationSchema = z.object({ + title: z.string().min(1).max(60).optional(), + body: z.string().min(1).optional(), + tags: z.array(planTagSchema).default([]), + shouldRemoveInboxTag: z.boolean().default(true), + note: z.string().min(1).optional(), +}); +const planOutputSchema = z.object({ + title: z.string().min(1).max(80).optional(), + body: z.string().min(1), + tags: z.array(planTagSchema).default([]), + note: z.string().min(1).optional(), +}); +const iterationMutationSchema = z.object({ + body: z.string().min(1).optional(), + tags: z.array(planTagSchema).default([]), + state: seekStateSchema, + note: z.string().min(1).optional(), +}); +const iterationOutputSchema = z.object({ + body: z.string().min(1).optional(), + tags: z.array(planTagSchema).default([]), + state: seekStateSchema, + note: z.string().min(1).optional(), +}); + +type RecordedSandboxOutput = + | { + path: string; + content: string; + size: number; + contentType?: string; + } + | { + path: string; + pointer: z.infer; + size: number; + contentType?: string; + }; + +export const perform = action({ + args: { + actionId: zid('actions'), + expectedCost: z.bigint().default(0n), + maxCost: z.bigint().default(0n), + }, + handler: async (ctx, { actionId, expectedCost, maxCost }): Promise => { + // + const currentUser = await currentActionUser(ctx); + return await performOwnedAction({ + ctx, + owner: currentUser._id, + actionId, + expectedCost, + maxCost, + }); + }, +}); + +export const _perform = internalAction({ + args: { + owner: zid('users'), + actionId: zid('actions'), + expectedCost: z.bigint().default(0n), + maxCost: z.bigint().default(0n), + }, + handler: async (ctx, args): Promise => await performOwnedAction({ ctx, ...args }), +}); + +async function performOwnedAction(args: { + ctx: ActionCtx; + owner: Doc<'users'>['_id']; + actionId: Doc<'actions'>['_id']; + expectedCost: bigint; + maxCost: bigint; +}) { + // + const context = await args.ctx.runQuery(internal.runtimeState._context, { + owner: args.owner, + actionId: args.actionId, + }); + const parsedContext = runtimeContextSchema.parse(context); + await args.ctx.runMutation(internal.runtimeState._upsertDetails, { + owner: args.owner, + actionId: args.actionId, + skill: parsedContext.skill?._id, + skillFile: parsedContext.skill?.file, + loop: parsedContext.loop?._id, + instructions: parsedContext.skill?.body, + input: parsedContext.action.args, + ...initialExecutionDetails(parsedContext), + }); + const estimatedCost = estimateClaimCost({ + context: parsedContext, + expectedCost: args.expectedCost, + maxCost: args.maxCost, + }); + const claimBlock = preClaimRuntimeBlock({ + context: parsedContext, + }); + if (claimBlock) { + await args.ctx.runMutation(internal.runtimeState._settle, { + owner: args.owner, + actionId: args.actionId, + status: settledActionStatusSchema.parse(claimBlock.status), + result: claimBlock.result, + costs: claimBlock.costs, + warnings: claimBlock.warnings, + }); + await args.ctx.runMutation(internal.runtimeState._upsertDetails, { + owner: args.owner, + actionId: args.actionId, + ...detailsForPerformed(claimBlock), + }); + return claimBlock; + } + const claim = claimResultSchema.parse( + await args.ctx.runMutation(internal.runtimeState._claim, { + owner: args.owner, + actionId: args.actionId, + expectedCost: estimatedCost.expectedCost, + maxCost: estimatedCost.maxCost, + }), + ); + if (claim.status !== 'running') return claim; + + const performed = await performClaimedAction({ + ctx: args.ctx, + owner: args.owner, + actionId: args.actionId, + context: parsedContext, + maxCost: estimatedCost.maxCost, + }); + + await args.ctx.runMutation(internal.runtimeState._settle, { + owner: args.owner, + actionId: args.actionId, + status: settledActionStatusSchema.parse(performed.status), + result: performed.result, + costs: performed.costs, + warnings: detailsForPerformed(performed).warnings, + }); + const details = detailsForPerformed(performed); + await args.ctx.runMutation(internal.runtimeState._upsertDetails, { + owner: args.owner, + actionId: args.actionId, + ...details, + }); + if (performed.status === 'succeeded') { + await args.ctx.runAction(internal.triggerIsolate._evaluate, { + owner: args.owner, + actionId: args.actionId, + }); + } + + return performed; +} + +async function currentActionUser(ctx: ActionCtx): Promise> { + // + const identity = await ctx.auth.getUserIdentity(); + if (!identity) throw Unauthorized(); + + const parsedAppUserId = zid('users').safeParse(identity.userId); + return await ctx.runQuery(internal.users._findCurrentByIdentity, { + authUserId: identity.subject, + appUserId: parsedAppUserId.success ? parsedAppUserId.data : undefined, + }); +} + +async function performClaimedAction(args: { + ctx: ActionCtx; + owner: Doc<'users'>['_id']; + actionId: Doc<'actions'>['_id']; + context: unknown; + maxCost: bigint; +}) { + // + const parsedContext = runtimeContextSchema.parse(args.context); + const skillKind = parsedContext.skill?.kind; + const skillName = parsedContext.action.skillKey; + + if (skillKind === 'think' || isManagedThinkSkill(skillName)) { + return await performThinkSkill({ + context: parsedContext, + maxCost: args.maxCost, + }); + } + + if (skillKind === 'execute' || skillName === 'execute') { + return await performSandboxSkill({ + ctx: args.ctx, + owner: args.owner, + actionId: args.actionId, + context: parsedContext, + }); + } + + if (!isTrustedInstinct(skillName)) { + return { + status: settledActionStatusSchema.parse('failed'), + result: { + text: `Unknown skill: ${skillName}`, + files: [], + }, + costs: [], + details: { + kind: 'unknown-skill', + }, + }; + } + + return { + status: settledActionStatusSchema.parse('succeeded'), + result: { + text: `${skillName} completed.`, + files: [], + }, + costs: [], + details: { + kind: 'instinct', + }, + }; +} + +function preClaimRuntimeBlock(args: { context: z.infer }) { + // + if (!usesRuntimeIntelligence(args.context)) return undefined; + + const resolvedIntelligence = resolveRuntimeIntelligence({ + context: args.context, + }); + if (!resolvedIntelligence.value?.deactivatedAt) return undefined; + + const provider = resolvedIntelligence.value.provider; + const warning = runtimeWarning({ + key: 'intelligence-deactivated', + severity: 'error', + source: 'claim', + message: `${resolvedIntelligence.selection} is deactivated and cannot run.`, + }); + + return { + status: settledActionStatusSchema.parse('failed'), + result: { + text: warning.message, + files: [], + metadata: { + kind: 'intelligence', + reason: 'deactivated', + selection: resolvedIntelligence.selection, + }, + }, + costs: [], + warnings: [warning], + details: { + provider: provider.provider, + model: provider.intelligence, + deactivatedAt: resolvedIntelligence.value.deactivatedAt, + }, + }; +} + +function initialExecutionDetails(context: z.infer) { + // + if (!usesRuntimeIntelligence(context)) return {}; + + const resolvedIntelligence = resolveRuntimeIntelligence({ context }); + if (!resolvedIntelligence.value) return {}; + + const provider = resolvedIntelligence.value.provider; + return { + provider: provider.provider, + model: provider.intelligence, + }; +} + +const runtimeContextSchema = z.object({ + action: z + .object({ + skillKey: z.string(), + intelligenceKey: z.string().optional(), + args: z.record(z.unknown()), + }) + .passthrough(), + skill: z + .object({ + _id: zid('skills').optional(), + file: zid('files').optional(), + kind: z.enum(['instinct', 'think', 'request', 'execute']), + fileName: z.string().optional(), + body: z.string().default(''), + }) + .passthrough() + .optional(), + loop: z + .object({ + _id: zid('loops'), + defaultIntelligenceKey: z.string().min(1).optional(), + }) + .passthrough() + .optional(), + file: z + .object({ + name: z.string(), + budget: fileBudgetSchema.optional(), + }) + .passthrough(), + tags: z.array( + z + .object({ + key: z.string(), + value: z.string(), + }) + .passthrough(), + ), + content: z.string(), + recentActions: z.array(z.record(z.unknown())), +}); + +const performedDetailsSchema = z + .object({ + result: actionResultSchema.optional(), + costs: z.array(costSchema).default([]), + warnings: z.array(actionWarningSchema).optional(), + details: z.record(z.unknown()).optional(), + }) + .passthrough(); + +function detailsForPerformed(value: unknown) { + // + const parsed = performedDetailsSchema.safeParse(value); + if (!parsed.success) { + return { + result: undefined, + costs: [], + warnings: undefined, + provider: undefined, + model: undefined, + output: undefined, + usage: undefined, + }; + } + + const details = executionDetails(parsed.data.details); + + return { + result: parsed.data.result, + costs: parsed.data.costs, + warnings: parsed.data.warnings, + ...details, + }; +} + +function executionDetails(details: Record | undefined) { + // + if (!details) return {}; + + const receipt: { + provider?: string; + model?: string; + output?: unknown; + usage?: unknown; + } = {}; + const provider = stringMetadata(details, 'provider'); + const model = stringMetadata(details, 'model'); + const output = details['output']; + const usage = details['usage']; + + if (provider) receipt.provider = provider; + if (model) receipt.model = model; + if (output !== undefined) receipt.output = output; + if (usage !== undefined) receipt.usage = usage; + + return receipt; +} + +function isManagedThinkSkill(skill: string) { + // + return managedThinkSkillNames.includes(skill); +} + +function usesRuntimeIntelligence(context: z.infer) { + // + return context.skill?.kind === 'think' || isManagedThinkSkill(context.action.skillKey); +} + +function isTrustedInstinct(skill: string) { + // + return trustedInstinctSkillNames.includes(skill); +} + +function runtimeIntelligenceWarnings(args: { + selection: string; + value: z.infer; + source: 'claim' | 'perform' | 'settle'; +}) { + // + const warnings = []; + if (args.value.deprecatedAt) { + warnings.push( + runtimeWarning({ + key: 'intelligence-deprecated', + severity: 'warning', + source: args.source, + message: `${args.selection} is deprecated and may be replaced soon.`, + }), + ); + } + + return warnings; +} + +function runtimeWarning(args: { + key: string; + severity: 'info' | 'warning' | 'error'; + source: 'claim' | 'perform' | 'settle'; + message: string; +}) { + // + return actionWarningSchema.parse({ + ...args, + createdAt: Date.now(), + }); +} + +const recentActionContextSchema = z + .object({ + index: z.number(), + skillKey: z.string(), + status: z.string(), + depth: z.number(), + args: z.record(z.unknown()).optional(), + result: z + .object({ + text: z.string().optional(), + metadata: z.record(z.unknown()).optional(), + }) + .optional(), + costs: z + .array( + z.object({ + amount: z.bigint(), + description: z.string().optional(), + symbol: z.string().optional(), + }), + ) + .default([]), + settledAt: z.number().optional(), + }) + .passthrough(); + +async function performThinkSkill(args: { context: z.infer; maxCost: bigint }) { + // + const resolvedIntelligence = resolveRuntimeIntelligence({ + context: args.context, + }); + if (!resolvedIntelligence.value) { + return { + status: settledActionStatusSchema.parse('failed'), + result: { + text: resolvedIntelligence.error, + files: [], + metadata: { + kind: 'intelligence', + reason: 'unknown-intelligence', + selection: resolvedIntelligence.selection, + }, + }, + costs: [], + details: { + kind: 'unknown-intelligence', + selection: resolvedIntelligence.selection, + }, + }; + } + + const warnings = runtimeIntelligenceWarnings({ + selection: resolvedIntelligence.selection, + value: resolvedIntelligence.value, + source: 'perform', + }); + const provider = resolvedIntelligence.value.provider; + const apiKey = providerApiKey(provider.provider); + if (!apiKey) { + return { + status: settledActionStatusSchema.parse('failed'), + result: { + text: `${providerLabel(provider.provider)} provider is not configured.`, + files: [], + metadata: { + kind: 'intelligence', + provider: provider.provider, + }, + }, + costs: [], + warnings, + details: { + provider: provider.provider, + model: provider.intelligence, + configured: false, + }, + }; + } + + const instructions = thinkSkillInstructions(args.context); + if (!instructions) { + return { + status: settledActionStatusSchema.parse('failed'), + result: { + text: `Skill ${args.context.action.skillKey} is missing instructions.`, + files: [], + metadata: { + kind: 'intelligence', + provider: provider.provider, + reason: 'missing-instructions', + }, + }, + costs: [], + warnings, + details: { + provider: provider.provider, + model: provider.intelligence, + configured: false, + reason: 'missing-instructions', + }, + }; + } + + const adapter = createStatelessIntelligenceAdapter({ + run: async (input) => + await runProviderIntelligence({ + apiKey, + provider, + input, + }), + }); + const intelligenceResult = await adapter + .run({ + intelligence: provider.intelligence, + instructions, + input: [ + { + role: 'user', + content: [ + { + type: 'input_text', + text: buildIntelligenceContext(args.context), + }, + ], + }, + ], + settings: { + ...structuredOutputSettings({ + provider: provider.provider, + skill: args.context.action.skillKey, + }), + include: provider.provider === 'openai' ? ['reasoning.encrypted_content'] : [], + billingIntelligence: resolvedIntelligence.value.intelligence, + provider: provider.provider, + }, + maxOutputTokens: maxOutputTokensForSkill(args.context.action.skillKey), + }) + .catch((error: unknown) => ({ + text: error instanceof Error ? error.message : 'Intelligence provider failed.', + costs: [], + providerItems: [], + reasoningSummaries: [], + metadata: { + failed: true, + }, + })); + if (hasProviderFailure(intelligenceResult.metadata)) { + return { + status: settledActionStatusSchema.parse('failed'), + result: { + text: intelligenceResult.text, + files: [], + metadata: { + kind: 'intelligence', + provider: provider.provider, + }, + }, + costs: intelligenceResult.costs, + warnings, + details: { + provider: provider.provider, + model: provider.intelligence, + store: false, + usage: usageFromProviderResult(intelligenceResult.metadata), + }, + }; + } + + const allowedInternalIdText = allowedInternalIdsText(args.context); + const planMutation = + args.context.action.skillKey === 'plan' + ? parsePlanMutation({ + text: intelligenceResult.text, + allowedInternalIdText, + }) + : undefined; + const iterationMutation = + args.context.action.skillKey === 'iterate' + ? parseIterationMutation({ + text: intelligenceResult.text, + content: args.context.content, + allowedInternalIdText, + }) + : undefined; + const seekState = iterationMutation + ? iterationMutation.state + : seekStateFor({ + skill: args.context.action.skillKey, + text: intelligenceResult.text, + content: args.context.content, + }); + if (planMutation?.status === 'failed') { + return { + status: settledActionStatusSchema.parse('failed'), + result: { + text: planMutation.error, + files: [], + metadata: { + kind: 'seek', + step: 'plan', + }, + }, + costs: intelligenceResult.costs, + warnings, + details: { + provider: provider.provider, + model: provider.intelligence, + store: false, + include: includeSetting({ + provider: provider.provider, + }), + output: intelligenceResult.providerItems, + usage: usageFromProviderResult(intelligenceResult.metadata), + }, + }; + } + const succeededPlan = planMutation?.status === 'succeeded' ? planMutation.plan : undefined; + const seekMetadata = seekState + ? { + kind: 'seek', + seekState, + } + : {}; + const resultText = sanitizeInternalIdLeaks({ + text: visibleThinkSkillText({ + skill: args.context.action.skillKey, + text: succeededPlan?.note ?? iterationMutation?.note ?? stripSeekStateMarker(intelligenceResult.text), + seekState, + }), + allowedInternalIdText, + }); + const metadata: Record = { + ...seekMetadata, + step: args.context.action.skillKey, + reasoningSummaries: intelligenceResult.reasoningSummaries, + }; + if (succeededPlan) metadata['planMutation'] = succeededPlan; + if (iterationMutation) metadata['iterationMutation'] = iterationMutation; + + return { + status: settledActionStatusSchema.parse('succeeded'), + result: { + text: resultText, + files: [], + metadata, + }, + costs: intelligenceResult.costs, + warnings, + details: { + provider: provider.provider, + model: provider.intelligence, + store: false, + include: includeSetting({ + provider: provider.provider, + }), + output: intelligenceResult.providerItems, + usage: usageFromProviderResult(intelligenceResult.metadata), + }, + }; +} + +function providerApiKey(provider: z.infer['provider']) { + // + if (provider === 'deepseek') return env.DEEPSEEK_API_KEY; + if (provider === 'moonshot') return env.MOONSHOT_API_KEY; + + return env.OPENAI_API_KEY; +} + +function providerLabel(provider: z.infer['provider']) { + // + if (provider === 'deepseek') return 'DeepSeek'; + if (provider === 'moonshot') return 'Kimi'; + + return 'OpenAI'; +} + +function includeSetting(args: { provider: z.infer['provider'] }) { + // + if (args.provider === 'openai') return ['reasoning.encrypted_content']; + + return []; +} + +function structuredOutputSettings(args: { + provider: z.infer['provider']; + skill: string; +}) { + // + if (!isStructuredThinkSkill(args.skill)) return {}; + + const settings: Record = { + responseFormat: 'json_object', + }; + + // Kimi thinking intelligences can spend the entire output budget on reasoning and + // return empty content. Structured mutation steps need compact JSON instead. + if (args.provider === 'moonshot') settings['thinking'] = { type: 'disabled' }; + + return settings; +} + +function isStructuredThinkSkill(skill: string) { + // + return skill === 'plan' || skill === 'iterate'; +} + +function maxOutputTokensForSkill(skill: string) { + // + if (isStructuredThinkSkill(skill)) return 4096; + + return 2048; +} + +function hasProviderFailure(metadata: Record) { + // + return metadata['failed'] === true; +} + +function usageFromProviderResult(metadata: Record) { + // + const usage = metadata['usage']; + return usage === undefined ? undefined : usage; +} + +function thinkSkillInstructions(context: z.infer) { + // + const parsed = z.string().min(1).safeParse(context.skill?.body); + if (parsed.success) return parsed.data; + + return undefined; +} + +function seekStateFor(args: { skill: string; text: string; content: string }) { + // + if (args.skill === 'plan') return seekStateSchema.parse('continue'); + if (args.skill !== 'iterate') return undefined; + + if (!args.text.trim()) return seekStateSchema.parse('blocked'); + + const match = /REACTOR_STATE:\s*(continue|done|blocked)/i.exec(args.text); + const parsed = seekStateSchema.safeParse(match?.[1]?.toLowerCase()); + if (parsed.success) { + return guardedSeekState({ + state: parsed.data, + body: args.content, + }); + } + + return seekStateSchema.parse('blocked'); +} + +function stripSeekStateMarker(text: string) { + // + return text.replace(/\n?\s*REACTOR_STATE:\s*(continue|done|blocked)\s*$/i, '').trim(); +} + +function visibleThinkSkillText(args: { + skill: string; + text: string; + seekState: z.infer | undefined; +}) { + // + if (args.text.trim()) return args.text; + if (args.skill === 'iterate' && args.seekState === 'blocked') { + return 'The iterate step did not produce visible output before its token limit, so the loop was blocked instead of continuing.'; + } + + return args.text; +} + +type PlanMutationResult = + | { + status: 'failed'; + error: string; + } + | { + status: 'succeeded'; + plan: z.infer; + }; + +function parsePlanMutation(args: { text: string; allowedInternalIdText: string }): PlanMutationResult { + // + const value = parseJsonObject(args.text); + const parsed = planOutputSchema.safeParse(value); + if (!parsed.success) { + return { + status: 'failed', + error: 'Plan output was not valid task-state JSON.', + }; + } + + return { + status: 'succeeded', + plan: planMutationSchema.parse({ + title: parsed.data.title ? parsed.data.title.slice(0, 60).trim() : undefined, + body: sanitizeInternalIdLeaks({ + text: parsed.data.body, + allowedInternalIdText: args.allowedInternalIdText, + }), + tags: mergePlanTags(parsed.data.tags), + shouldRemoveInboxTag: true, + note: sanitizeInternalIdLeaks({ + text: parsed.data.note ?? 'Updated task plan.', + allowedInternalIdText: args.allowedInternalIdText, + }), + }), + }; +} + +function parseIterationMutation(args: { text: string; content: string; allowedInternalIdText: string }) { + // + const value = parseJsonObject(args.text); + const parsed = iterationOutputSchema.safeParse(value); + if (!parsed.success) return undefined; + + const body = parsed.data.body + ? sanitizeInternalIdLeaks({ + text: parsed.data.body, + allowedInternalIdText: args.allowedInternalIdText, + }) + : undefined; + const state = guardedSeekState({ + state: parsed.data.state, + body: body ?? args.content, + }); + + return iterationMutationSchema.parse({ + body, + tags: mergeIterationTags({ + tags: parsed.data.tags, + state, + }), + state, + note: sanitizeInternalIdLeaks({ + text: parsed.data.note ?? visibleIterationNote(state), + allowedInternalIdText: args.allowedInternalIdText, + }), + }); +} + +function guardedSeekState(args: { state: z.infer; body: string }) { + // + if (args.state !== 'done') return args.state; + if (needsUserDirection(args.body)) return seekStateSchema.parse('blocked'); + if (hasVisibleRemainingWork(args.body)) return seekStateSchema.parse('continue'); + + return args.state; +} + +function hasVisibleRemainingWork(body: string) { + // + return ( + /(^|\n)\s*(?:[-*]|\d+[.)])\s+\[\s\]/.test(body) || /(^|\n)\s*(?:#{1,6}\s*)?next steps?(?:\s|\(|:|$)/i.test(body) + ); +} + +function needsUserDirection(body: string) { + // + return /\b(?:awaiting|pending)\s+user\s+(?:direction|input|approval)\b/i.test(body); +} + +function mergeIterationTags(args: { tags: z.infer[]; state: z.infer }) { + // + const byKey = new Map>(); + + for (const tag of args.tags) { + if (tag.key === 'status' && tag.value === 'done' && args.state !== 'done') continue; + byKey.set(tag.key, tag); + } + + if (args.state === 'done') byKey.set('status', { key: 'status', value: 'done' }); + + return Array.from(byKey.values()); +} + +function visibleIterationNote(state: z.infer) { + // + if (state === 'done') return 'Finished iteration.'; + if (state === 'blocked') return 'Blocked iteration.'; + + return 'Continued iteration.'; +} + +function allowedInternalIdsText(context: z.infer) { + // + return [ + context.file.name, + context.content, + stringMetadata(context.action.args, 'message') ?? '', + stringMetadata(context.action.args, 'text') ?? '', + ].join('\n'); +} + +function sanitizeInternalIdLeaks(args: { text: string; allowedInternalIdText: string }) { + // + const blockedIds = internalIdsIn(args.text).filter((id) => !args.allowedInternalIdText.includes(id)); + if (blockedIds.length === 0) return args.text; + + const blocked = new Set(blockedIds); + const lines = args.text.split('\n').filter((line) => { + for (const id of blocked) { + if (line.includes(id)) return false; + } + + return true; + }); + + return lines.join('\n').trim(); +} + +function internalIdsIn(text: string) { + // + return Array.from(new Set(text.match(/\b[a-z][a-z0-9]{24,}\b/g) ?? [])); +} + +function parseJsonObject(text: string) { + // + const candidates = []; + const trimmed = text.trim(); + if (trimmed) candidates.push(trimmed); + + const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(text); + if (fenced?.[1]) candidates.unshift(fenced[1].trim()); + + const firstBrace = text.indexOf('{'); + const lastBrace = text.lastIndexOf('}'); + if (firstBrace >= 0 && lastBrace > firstBrace) { + candidates.push(text.slice(firstBrace, lastBrace + 1)); + } + + for (const candidate of candidates) { + try { + const parsed: unknown = JSON.parse(candidate); + return parsed; + } catch { + continue; + } + } + + return undefined; +} + +function mergePlanTags(tags: z.infer[]) { + // + const byKey = new Map>(); + byKey.set('kind', { key: 'kind', value: 'task' }); + byKey.set('status', { key: 'status', value: 'active' }); + + for (const tag of tags) { + byKey.set(tag.key, tag); + } + + return Array.from(byKey.values()); +} + +async function runProviderIntelligence(args: { + apiKey: string; + provider: z.infer; + input: IntelligenceRunInput & { store: false }; +}) { + // + if (args.provider.provider === 'openai') { + return await runOpenAiResponses({ + apiKey: args.apiKey, + input: args.input, + }); + } + + if (args.provider.provider === 'deepseek') { + return await runChatCompletions({ + apiKey: args.apiKey, + endpoint: 'https://api.deepseek.com/chat/completions', + provider: args.provider.provider, + input: args.input, + }); + } + + return await runChatCompletions({ + apiKey: args.apiKey, + endpoint: 'https://api.moonshot.ai/v1/chat/completions', + provider: args.provider.provider, + input: args.input, + }); +} + +async function runOpenAiResponses(args: { apiKey: string; input: IntelligenceRunInput & { store: false } }) { + // + const body: Record = { + model: args.input.intelligence, + store: false, + instructions: args.input.instructions, + input: args.input.input, + max_output_tokens: args.input.maxOutputTokens, + }; + const include = responseIncludeSetting(args.input.settings); + if (include.length > 0) body['include'] = include; + + const response = await fetch('https://api.openai.com/v1/responses', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${args.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error(`Intelligence provider failed with ${response.status}.`); + } + + const parsed = openAiResponseSchema.parse(await response.json()); + const text = parsed.output_text ?? extractOutputText(parsed.output); + const reasoningSummaries = extractReasoningSummaries(parsed.output); + + return { + text, + costs: intelligenceUsageCosts({ + intelligence: billingIntelligence(args.input.settings) ?? args.input.intelligence, + usage: parsed.usage, + }), + providerItems: parsed.output, + reasoningSummaries, + metadata: { + status: parsed.status, + incompleteDetails: parsed.incomplete_details, + usage: parsed.usage, + }, + }; +} + +async function runChatCompletions(args: { + apiKey: string; + endpoint: string; + provider: z.infer['provider']; + input: IntelligenceRunInput & { store: false }; +}) { + // + const body: Record = { + model: args.input.intelligence, + messages: chatCompletionMessages(args.input), + temperature: numberSetting(args.input.settings, 'temperature'), + }; + if (args.provider === 'moonshot') { + body['max_completion_tokens'] = args.input.maxOutputTokens; + } else { + body['max_tokens'] = args.input.maxOutputTokens; + } + + if (stringSetting(args.input.settings, 'responseFormat') === 'json_object') { + body['response_format'] = { type: 'json_object' }; + } + + const thinking = args.input.settings['thinking']; + if (args.provider === 'moonshot' && isPlainRecord(thinking)) { + body['thinking'] = thinking; + } + + const response = await fetch(args.endpoint, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${args.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error(`${providerLabel(args.provider)} provider failed with ${response.status}.`); + } + + const parsed = chatCompletionResponseSchema.parse(await response.json()); + const text = extractChatCompletionText(parsed.choices); + + return { + text, + costs: chatCompletionUsageCosts({ + intelligence: billingIntelligence(args.input.settings) ?? args.input.intelligence, + usage: parsed.usage, + }), + providerItems: chatCompletionProviderItems(parsed.choices), + reasoningSummaries: [], + metadata: { + id: parsed.id, + object: parsed.object, + created: parsed.created, + model: parsed.model, + usage: parsed.usage, + }, + }; +} + +function intelligenceUsageCosts(args: { intelligence: string; usage: Record | undefined }) { + // + const parsed = openAiUsageSchema.safeParse(args.usage ?? {}); + if (!parsed.success) return []; + + const cost = estimateIntelligenceCost({ + intelligence: args.intelligence, + inputTokens: parsed.data.input_tokens, + outputTokens: parsed.data.output_tokens, + }); + if (!cost) return []; + + return [cost]; +} + +function chatCompletionUsageCosts(args: { intelligence: string; usage: Record | undefined }) { + // + const parsed = chatCompletionUsageSchema.safeParse(args.usage ?? {}); + if (!parsed.success) return []; + + const cost = estimateIntelligenceCost({ + intelligence: args.intelligence, + inputTokens: parsed.data.prompt_tokens, + outputTokens: parsed.data.completion_tokens, + }); + if (!cost) return []; + + return [cost]; +} + +function responseIncludeSetting(settings: Record) { + // + const parsed = z.array(z.string()).safeParse(settings['include']); + if (!parsed.success) return []; + + return parsed.data; +} + +function numberSetting(settings: Record, key: string) { + // + const value = settings[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function stringSetting(settings: Record, key: string) { + // + const value = settings[key]; + return typeof value === 'string' ? value : undefined; +} + +function isPlainRecord(value: unknown): value is Record { + // + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function billingIntelligence(settings: Record) { + // + const value = settings['billingIntelligence']; + return typeof value === 'string' ? value : undefined; +} + +function chatCompletionMessages(input: IntelligenceRunInput) { + // + const messages = []; + if (input.instructions.trim()) { + messages.push({ + role: 'system', + content: input.instructions, + }); + } + + const userText = input.input + .map(inputItemText) + .filter((text) => Boolean(text.trim())) + .join('\n\n'); + messages.push({ + role: 'user', + content: userText || 'Continue.', + }); + + return messages; +} + +function inputItemText(item: Record) { + // + const content = item['content']; + if (Array.isArray(content)) { + return content + .map(inputContentText) + .filter((text) => Boolean(text.trim())) + .join('\n'); + } + + const text = item['text']; + if (typeof text === 'string') return text; + + return stringifyForIntelligence(item); +} + +function inputContentText(value: unknown) { + // + const parsed = inputTextContentSchema.safeParse(value); + if (parsed.success) return parsed.data.text; + + return ''; +} + +function extractChatCompletionText(choices: z.infer[]) { + // + const parts = []; + for (const choice of choices) { + if (choice.message.content) parts.push(choice.message.content); + } + + return parts.join('\n'); +} + +function chatCompletionProviderItems(choices: z.infer[]) { + // + return choices.map((choice) => ({ + index: choice.index, + finishReason: choice.finish_reason, + message: publicChatCompletionMessage(choice.message), + })); +} + +function publicChatCompletionMessage(message: z.infer['message']) { + // + const visible: Record = {}; + const reasoning = providerReasoningSummary(message); + + for (const [key, value] of Object.entries(message)) { + if (isReasoningField(key)) continue; + visible[key] = value; + } + + if (reasoning) visible['reasoning'] = reasoning; + + return visible; +} + +function providerReasoningSummary(message: z.infer['message']) { + // + const fields = []; + let characterCount = 0; + + for (const [key, value] of Object.entries(message)) { + if (!isReasoningField(key) || !hasReasoningValue(value)) continue; + fields.push(key); + if (typeof value === 'string') characterCount += value.length; + } + + if (fields.length === 0) return undefined; + + return { + kind: 'provider-hidden-reasoning', + fields, + characterCount, + }; +} + +function isReasoningField(key: string) { + // + return key.toLowerCase().includes('reasoning'); +} + +function hasReasoningValue(value: unknown) { + // + if (value === null || value === undefined) return false; + if (typeof value === 'string') return value.trim().length > 0; + if (Array.isArray(value)) return value.length > 0; + if (typeof value === 'object') return Object.keys(value).length > 0; + + return true; +} + +async function performSandboxSkill(args: { + ctx: ActionCtx; + owner: Doc<'users'>['_id']; + actionId: Doc<'actions'>['_id']; + context: z.infer; +}) { + // + const apiKey = env.DAYTONA_API_KEY; + if (!apiKey) { + return { + status: settledActionStatusSchema.parse('failed'), + result: { + text: 'Sandbox provider is not configured.', + files: [], + metadata: { + kind: 'sandbox', + provider: 'daytona', + configured: false, + }, + }, + costs: [], + details: { + provider: 'daytona', + output: { + configured: false, + }, + configured: false, + }, + }; + } + + const actionOptions = sandboxOptionsSchema.safeParse(args.context.action.args); + if (!actionOptions.success) { + return failedSandboxResult({ + text: 'Sandbox action arguments are invalid.', + metadata: { + issues: actionOptions.error.issues, + }, + }); + } + + const skillFile = sandboxSkillFile(args.context); + const executeFile = executeInlineFile(args.context.action); + const command = + actionOptions.data.command ?? + executeFile?.command ?? + (skillFile ? sandboxCommandFor(skillFile.path) : undefined); + if (!command) { + return failedSandboxResult({ + text: 'Sandbox command is not configured.', + metadata: {}, + }); + } + + const settingsInput: Record = { apiKey }; + if (env.DAYTONA_API_URL) settingsInput['apiUrl'] = env.DAYTONA_API_URL; + if (env.DAYTONA_TARGET) settingsInput['target'] = env.DAYTONA_TARGET; + if (env.DAYTONA_IMAGE) settingsInput['image'] = env.DAYTONA_IMAGE; + const settings = daytonaSettingsSchema.parse(settingsInput); + const declaredOutputs = actionOptions.data.outputs; + const adapter = createDaytonaReactorSandbox({ + settings, + declaredOutputs, + }); + const timeoutMs = actionOptions.data.timeoutMs ?? 60_000; + + try { + const run = await adapter.run({ + actionId: args.actionId, + files: sandboxFiles({ + context: args.context, + skillFile, + actionFiles: executeFile ? actionOptions.data.files.concat(executeFile.file) : actionOptions.data.files, + }), + command, + env: sandboxEnv({ + context: args.context, + actionEnv: actionOptions.data.env, + }), + timeoutMs, + }); + const recorded = await recordSandboxOutputs({ + ctx: args.ctx, + owner: args.owner, + actionId: args.actionId, + declaredOutputs, + outputs: run.declaredOutputs, + }); + const succeededStatus = 'succeeded'; + const failedStatus = 'failed'; + + return { + status: run.exitCode === 0 ? succeededStatus : failedStatus, + result: { + text: sandboxResultText(run.stdout, run.stderr), + files: recorded.files, + metadata: { + kind: 'sandbox', + provider: 'daytona', + runId: run.runId, + exitCode: run.exitCode, + }, + }, + costs: [], + details: { + provider: 'daytona', + output: { + runId: run.runId, + exitCode: run.exitCode, + metadata: run.metadata, + }, + }, + }; + } catch (error: unknown) { + return failedSandboxResult({ + text: error instanceof Error ? error.message : 'Sandbox provider failed.', + metadata: { + configured: true, + }, + }); + } +} + +function failedSandboxResult(args: { text: string; metadata: Record }) { + // + return { + status: settledActionStatusSchema.parse('failed'), + result: { + text: args.text, + files: [], + metadata: { + kind: 'sandbox', + provider: 'daytona', + ...args.metadata, + }, + }, + costs: [], + details: { + provider: 'daytona', + output: args.metadata, + }, + }; +} + +function sandboxFiles(args: { + context: z.infer; + skillFile: z.infer | undefined; + actionFiles: z.infer[]; +}) { + // + const encoder = new TextEncoder(); + const current = [ + { + path: '/workspace/current.md', + content: encoder.encode(args.context.content), + }, + ]; + const configured = args.skillFile + ? [ + { + path: args.skillFile.path, + content: encoder.encode(args.skillFile.content), + }, + ] + : []; + const action = args.actionFiles.map((file) => ({ + path: file.path, + content: encoder.encode(file.content), + })); + + return current.concat(configured).concat(action); +} + +function sandboxEnv(args: { context: z.infer; actionEnv: Record }) { + // + return { + ...args.actionEnv, + REACTOR_CURRENT_FILE: '/workspace/current.md', + REACTOR_FILE_NAME: args.context.file.name, + }; +} + +function sandboxSkillFile(context: z.infer) { + // + const body = context.skill?.body; + if (!body?.trim()) return undefined; + + return sandboxInlineFileSchema.parse({ + path: `/workspace/${safeSandboxFileName(context.skill?.fileName ?? 'skill.js')}`, + content: materializeDaytonaWorkspaceText(body), + }); +} + +function safeSandboxFileName(name: string) { + // + const trimmed = name.trim() || 'skill.js'; + return trimmed.replace(/[^A-Za-z0-9._@+-]/g, '_'); +} + +function sandboxCommandFor(path: string) { + // + if (path.endsWith('.py')) return `python ${path}`; + + return `node ${path}`; +} + +async function recordSandboxOutputs(args: { + ctx: ActionCtx; + owner: Doc<'users'>['_id']; + actionId: Doc<'actions'>['_id']; + declaredOutputs: z.infer[]; + outputs: Array<{ + path: string; + bytes: Uint8Array; + }>; +}) { + // + if (args.outputs.length === 0) { + return { + files: [], + }; + } + + const decoder = new TextDecoder(); + const recordedOutputs: RecordedSandboxOutput[] = []; + let adapter: ReturnType | undefined; + + for (const output of args.outputs) { + const contentType = declaredContentType(output.path, args.declaredOutputs); + if (shouldStoreSandboxOutputInline({ output, contentType })) { + recordedOutputs.push({ + path: output.path, + content: decoder.decode(output.bytes), + size: output.bytes.byteLength, + contentType, + }); + continue; + } + + adapter ??= createConfiguredObjectStorageAdapter(); + const write = await adapter.write({ + bytes: output.bytes, + contentType, + }); + const pointer = objectContentPointerSchema.parse({ + kind: 'object', + storageKey: write.key, + size: write.size, + contentType: write.contentType, + }); + + recordedOutputs.push({ + path: output.path, + pointer, + size: write.size, + contentType: write.contentType, + }); + } + + return recordedSandboxOutputsSchema.parse( + await args.ctx.runMutation(internal.runtimeState._recordSandboxOutputs, { + owner: args.owner, + actionId: args.actionId, + outputs: recordedOutputs, + }), + ); +} + +function declaredContentType(path: string, declaredOutputs: z.infer[]) { + // + for (const output of declaredOutputs) { + if (output.path === path) return output.contentType; + } + + return undefined; +} + +function shouldStoreSandboxOutputInline(args: { + output: { + bytes: Uint8Array; + }; + contentType: string | undefined; +}) { + // + if (args.output.bytes.byteLength > env.MAX_REACTOR_INLINE_CONTENT_BYTES) return false; + return isInlineTextContentType(args.contentType); +} + +function isInlineTextContentType(contentType: string | undefined) { + // + if (!contentType) return true; + + const lower = contentType.toLowerCase(); + if (lower.startsWith('text/')) return true; + if (lower === 'application/json') return true; + if (lower === 'application/javascript') return true; + if (lower === 'application/xml') return true; + if (lower === 'application/x-ndjson') return true; + if (lower.endsWith('+json')) return true; + if (lower.endsWith('+xml')) return true; + + return false; +} + +function sandboxResultText(stdout: string, stderr: string) { + // + const parts = []; + if (stdout) parts.push(stdout); + if (stderr) parts.push(`stderr:\n${stderr}`); + if (parts.length === 0) return 'Sandbox command completed.'; + + return parts.map((part) => truncate(part, 4_000)).join('\n\n'); +} + +function executeInlineFile(action: z.infer['action']) { + // + if (action.skillKey !== 'execute') return undefined; + + const parsed = executeCodeSchema.safeParse(action.args); + if (!parsed.success) return undefined; + + const extension = parsed.data.language === 'python' ? 'py' : 'js'; + const path = `/workspace/action.${extension}`; + const command = parsed.data.language === 'python' ? `python3 ${path}` : `node ${path}`; + + return { + command, + file: sandboxInlineFileSchema.parse({ + path, + content: materializeDaytonaWorkspaceText(parsed.data.code), + }), + }; +} + +function normalizeSandboxPath(path: string) { + // + const trimmed = path.trim(); + const absolute = trimmed.startsWith('/workspace/') ? trimmed : `/workspace/${trimmed.replace(/^workspace\//, '')}`; + + if (absolute.includes('..')) return null; + if (!/^\/workspace(?:\/[A-Za-z0-9._@+-]+)+$/.test(absolute)) return null; + + return absolute; +} + +function truncate(value: string, limit: number) { + // + if (value.length <= limit) return value; + + return `${value.slice(0, limit)}\n... truncated`; +} + +function findIntelligenceSelection(context: Pick, 'action' | 'loop'>) { + // + const intelligenceKey = context.action.intelligenceKey; + if (intelligenceKey?.trim()) return intelligenceKey; + const loopDefault = context.loop?.defaultIntelligenceKey; + if (loopDefault?.trim()) return loopDefault; + + return 'Cheap'; +} + +function resolveRuntimeIntelligence(args: { + context: Pick, 'action' | 'loop' | 'file'>; +}) { + // + const selection = findIntelligenceSelection(args.context); + try { + return { + value: runtimeIntelligenceSelectionSchema.parse( + referenceIntelligenceSelection({ + key: selection, + }), + ), + selection, + error: undefined, + }; + } catch (error) { + return { + value: undefined, + selection, + error: error instanceof Error ? error.message : `Unknown intelligence selection ${selection}`, + }; + } +} + +function estimateClaimCost(args: { + context: z.infer; + expectedCost: bigint; + maxCost: bigint; +}) { + // + if (args.maxCost > 0n) { + return { + expectedCost: args.expectedCost, + maxCost: args.maxCost, + }; + } + + if (args.expectedCost > 0n) { + return { + expectedCost: args.expectedCost, + maxCost: withPredictionMargin(args.expectedCost), + }; + } + + const skillKind = args.context.skill?.kind; + if (skillKind !== 'think' && args.context.action.skillKey !== 'think') { + return { + expectedCost: 0n, + maxCost: 0n, + }; + } + + const resolvedIntelligence = resolveRuntimeIntelligence({ + context: args.context, + }); + if (!resolvedIntelligence.value) { + return { + expectedCost: 0n, + maxCost: 0n, + }; + } + const cost = estimateIntelligenceCost({ + intelligence: resolvedIntelligence.value.intelligence, + inputTokens: estimateTokens(buildIntelligenceContext(args.context)), + outputTokens: 1024, + }); + const expectedCost = cost?.amount ?? 0n; + + return { + expectedCost, + maxCost: withPredictionMargin(expectedCost), + }; +} + +function estimateTokens(text: string) { + // + return Math.max(1, Math.ceil(text.length / env.CHAR_PER_TOKEN)); +} + +function withPredictionMargin(amount: bigint) { + // + if (amount <= 0n) return 0n; + + return amount + (amount * BigInt(env.COST_PREDICTION_MARGIN)) / 100n; +} + +function buildIntelligenceContext(context: z.infer) { + // + const latestMessage = latestUserMessage(context.recentActions); + const sections = [ + `File: ${context.file.name}`, + `Skill: ${context.action.skillKey}`, + `Tags: ${context.tags.map((tag) => `${tag.key}=${tag.value}`).join(', ')}`, + ]; + if (latestMessage) sections.push(`Latest user message:\n${latestMessage}`); + sections.push( + `Content:\n${context.content}`, + `Args:\n${stringifyForIntelligence(intelligenceVisibleActionArgs(context.action.args))}`, + `Recent actions:\n${context.recentActions.map(summarizeRecentAction).join('\n')}`, + ); + + return sections.join('\n\n'); +} + +function latestUserMessage(values: Record[]) { + // + for (const value of values) { + const parsed = recentActionContextSchema.safeParse(value); + if (!parsed.success) continue; + if (parsed.data.skillKey !== 'say') continue; + + const message = stringMetadata(parsed.data.args, 'message') ?? stringMetadata(parsed.data.args, 'text'); + if (message) return truncate(message, 1000); + } + + return undefined; +} + +function summarizeRecentAction(value: Record) { + // + const parsed = recentActionContextSchema.safeParse(value); + if (!parsed.success) return '- action unavailable'; + + const action = parsed.data; + const resultText = action.result?.text?.trim(); + const result = resultText ? ` result=${JSON.stringify(truncate(resultText, 800))}` : ''; + const seekState = stringMetadata(action.result?.metadata, 'seekState'); + const state = seekState ? ` seekState=${seekState}` : ''; + const costs = + action.costs.length > 0 ? ` costs=${action.costs.map((cost) => cost.amount.toString()).join('+')}` : ''; + const args = summarizeActionArgs(action.args); + + return `- #${action.index} ${action.skillKey} status=${action.status} depth=${action.depth}${state}${args}${result}${costs}`; +} + +function summarizeActionArgs(value: Record | undefined) { + // + if (!value) return ''; + + const message = stringMetadata(value, 'message') ?? stringMetadata(value, 'text'); + if (message) return ` args=${JSON.stringify({ message: truncate(message, 280) })}`; + + const keys = Object.keys(value).filter((key) => !isInternalActionArg(key)); + if (keys.length === 0) return ''; + + return ` argsKeys=${keys.slice(0, 8).join(',')}`; +} + +function intelligenceVisibleActionArgs(value: Record) { + // + const visible: Record = {}; + const message = stringMetadata(value, 'message') ?? stringMetadata(value, 'text'); + if (message) { + visible['message'] = truncate(message, 1000); + } + + return visible; +} + +function isInternalActionArg(key: string) { + // + return key === 'trigger' || key === 'maxDepth' || key === 'settings'; +} + +function stringMetadata(value: Record | undefined, key: string) { + // + const item = value?.[key]; + return typeof item === 'string' ? item : undefined; +} + +function stringifyForIntelligence(value: unknown) { + // + return ( + JSON.stringify( + value, + (_key: string, item: unknown) => { + if (typeof item === 'bigint') return item.toString(); + return item; + }, + 2, + ) ?? '' + ); +} + +function extractOutputText(output: Array>) { + // + const parts = []; + + for (const item of output) { + const parsed = openAiMessageSchema.safeParse(item); + if (!parsed.success) continue; + + for (const content of parsed.data.content) { + parts.push(content.text); + } + } + + return parts.join('\n'); +} + +function extractReasoningSummaries(output: Array>) { + // + const summaries = []; + + for (const item of output) { + const parsed = openAiReasoningSchema.safeParse(item); + if (!parsed.success) continue; + + for (const summary of parsed.data.summary) { + summaries.push(summary.text); + } + } + + return summaries; +} diff --git a/apps/meseeks/convex/runtimeState.ts b/apps/meseeks/convex/runtimeState.ts new file mode 100644 index 00000000..3507140b --- /dev/null +++ b/apps/meseeks/convex/runtimeState.ts @@ -0,0 +1,549 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import { internalMutation, internalQuery } from 'lib/convex'; +import { NotFound } from 'lib/errors'; +import { actionResultFileSchema, actionResultSchema, actionWarningSchema, costSchema } from 'schemas/actionSchema'; +import { objectContentPointerSchema } from 'schemas/fileSchema'; +import { + createFile, + ensureFileOwner, + findChildByName, + findTags, + moveFile, + renderCurrentContent, + setFileTags, + writeFileContent, +} from './files.private'; +import { findLoopByKey } from './loops.private'; +import { claimAction, recentActionsForFile, settleAction } from './reactor.private'; +import { resolveSkillForRuntime } from './skills.private'; +import type { Id } from './_generated/dataModel'; +import type { MutationCtx } from './_generated/server'; + +const recordedSandboxOutputSchema = z.union([ + z.object({ + path: z.string().min(1), + content: z.string(), + size: z.number().int().nonnegative(), + contentType: z.string().min(1).optional(), + }), + z.object({ + path: z.string().min(1), + pointer: objectContentPointerSchema, + size: z.number().int().nonnegative(), + contentType: z.string().min(1).optional(), + }), +]); +const planMutationSchema = z.object({ + title: z.string().min(1).max(60).optional(), + body: z.string().min(1).optional(), + tags: z + .array( + z.object({ + key: z.string().min(1), + value: z.string(), + }), + ) + .default([]), + shouldRemoveInboxTag: z.boolean().default(true), + note: z.string().min(1).optional(), +}); +const seekStateSchema = z.enum(['continue', 'done', 'blocked']); +const iterationMutationSchema = z.object({ + body: z.string().min(1).optional(), + tags: z + .array( + z.object({ + key: z.string().min(1), + value: z.string(), + }), + ) + .default([]), + state: seekStateSchema, + note: z.string().min(1).optional(), +}); + +export const _context = internalQuery({ + args: { + owner: zid('users'), + actionId: zid('actions'), + }, + handler: async (ctx, { owner, actionId }) => { + // + const action = await ctx.db.get(actionId); + if (!action || action.owner !== owner) throw NotFound(); + const file = await ensureFileOwner(ctx, { + fileId: action.file, + owner, + }); + const tags = await findTags(ctx, { + file: action.file, + owner, + }); + const recentActions = await recentActionsForFile(ctx, { + file: action.file, + limit: 20, + }); + const loop = action.loopKey + ? await findLoopByKey(ctx, { + owner, + key: action.loopKey, + }) + : undefined; + + return { + action, + skill: await resolveSkillForRuntime(ctx, { + owner, + skillKey: action.skillKey, + }), + loop, + file, + tags, + content: await renderCurrentContent(ctx, { file }), + recentActions, + }; + }, +}); + +export const _claim = internalMutation({ + args: { + owner: zid('users'), + actionId: zid('actions'), + expectedCost: z.bigint(), + maxCost: z.bigint(), + }, + handler: async (ctx, { owner, actionId, expectedCost, maxCost }) => { + // + const action = await ctx.db.get(actionId); + if (!action || action.owner !== owner) throw NotFound(); + + return await claimAction(ctx, { + actionId, + expectedCost, + maxCost, + }); + }, +}); + +export const _upsertDetails = internalMutation({ + args: { + owner: zid('users'), + actionId: zid('actions'), + skill: zid('skills').optional(), + skillFile: zid('files').optional(), + loop: zid('loops').optional(), + provider: z.string().min(1).optional(), + model: z.string().min(1).optional(), + instructions: z.string().optional(), + input: z.unknown().optional(), + output: z.unknown().optional(), + usage: z.unknown().optional(), + result: actionResultSchema.optional(), + costs: z.array(costSchema).optional(), + warnings: z.array(actionWarningSchema).optional(), + }, + handler: async (ctx, { owner, actionId, ...value }) => { + // + const action = await ctx.db.get(actionId); + if (!action || action.owner !== owner) throw NotFound(); + const now = Date.now(); + const existing = await ctx.db + .query('details') + .withIndex('by_action', (q) => q.eq('action', actionId)) + .unique(); + + const detail = detailForRuntimeValue({ + actionId, + value, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }); + + if (existing) { + await ctx.db.patch(existing._id, detail); + return existing._id; + } + + return await ctx.db.insert('details', detail); + }, +}); + +function detailForRuntimeValue(args: { + actionId: Id<'actions'>; + value: { + skill?: Id<'skills'>; + skillFile?: Id<'files'>; + loop?: Id<'loops'>; + provider?: string; + model?: string; + instructions?: string; + input?: unknown; + output?: unknown; + usage?: unknown; + result?: z.infer; + costs?: Array>; + warnings?: Array>; + }; + createdAt: number; + updatedAt: number; +}) { + // + if (args.value.provider && args.value.model) { + return { + kind: z.literal('model').parse('model'), + action: args.actionId, + skill: args.value.skill, + skillFile: args.value.skillFile, + provider: args.value.provider, + model: args.value.model, + input: args.value.input, + output: args.value.output, + metadata: args.value.result?.metadata, + usage: args.value.usage, + costs: args.value.costs ?? [], + warnings: args.value.warnings, + createdAt: args.createdAt, + updatedAt: args.updatedAt, + }; + } + + if (args.value.provider === 'daytona') { + return { + kind: z.literal('execution').parse('execution'), + action: args.actionId, + skill: args.value.skill, + skillFile: args.value.skillFile, + outputFiles: args.value.result?.files.map((file) => file.file) ?? [], + costs: args.value.costs ?? [], + warnings: args.value.warnings, + createdAt: args.createdAt, + updatedAt: args.updatedAt, + }; + } + + return { + kind: z.literal('mutation').parse('mutation'), + action: args.actionId, + skill: args.value.skill, + summary: args.value.result?.text ?? args.value.warnings?.[0]?.message ?? 'Action settled.', + resultFile: args.value.result?.files[0]?.file, + metadata: args.value.result?.metadata, + warnings: args.value.warnings, + createdAt: args.createdAt, + updatedAt: args.updatedAt, + }; +} + +export const _settle = internalMutation({ + args: { + owner: zid('users'), + actionId: zid('actions'), + status: z.enum(['succeeded', 'failed', 'skipped']), + result: actionResultSchema.optional(), + costs: z.array(costSchema).default([]), + warnings: z.array(actionWarningSchema).optional(), + }, + handler: async (ctx, { owner, ...args }) => { + // + const action = await ctx.db.get(args.actionId); + if (!action || action.owner !== owner) throw NotFound(); + + await applyPlanMutation(ctx, { + owner, + actionId: args.actionId, + result: args.result, + }); + await applyIterationMutation(ctx, { + owner, + actionId: args.actionId, + result: args.result, + }); + const settled = await settleAction(ctx, { + ...args, + shouldReleaseAvailableBudget: shouldReleaseAvailableBudgetOnSettle(args.result), + }); + await applySettledResultState(ctx, { + owner, + actionId: args.actionId, + result: args.result, + }); + + return settled; + }, +}); + +async function applyPlanMutation( + ctx: MutationCtx, + args: { + owner: Id<'users'>; + actionId: Id<'actions'>; + result: z.infer | undefined; + }, +) { + // + const mutation = planMutationFromResult(args.result); + if (!mutation) return undefined; + + const action = await ctx.db.get(args.actionId); + if (!action || action.owner !== args.owner || action.skillKey !== 'plan') throw NotFound(); + + const file = await ensureFileOwner(ctx, { + fileId: action.file, + owner: args.owner, + }); + + if (mutation.title && mutation.title !== file.name) { + await moveFile(ctx, { + fileId: action.file, + owner: args.owner, + author: args.actionId, + newName: mutation.title, + shouldCreateAction: false, + }); + } + + if (mutation.body !== undefined) { + const current = await renderCurrentContent(ctx, { file }); + const body = sanitizeInternalIdLeaks({ + text: mutation.body, + allowedInternalIdText: current, + }); + if (body.trim() && current !== body) { + await writeFileContent(ctx, { + fileId: action.file, + owner: args.owner, + author: args.actionId, + content: body, + shouldCreateAction: false, + }); + } + } + + if (mutation.tags.length > 0 || mutation.shouldRemoveInboxTag) { + await setFileTags(ctx, { + owner: args.owner, + file: action.file, + author: args.actionId, + tags: mutation.tags, + shouldRemoveInboxTag: mutation.shouldRemoveInboxTag, + shouldCreateAction: false, + actionSkill: 'plan', + }); + } +} + +async function applyIterationMutation( + ctx: MutationCtx, + args: { + owner: Id<'users'>; + actionId: Id<'actions'>; + result: z.infer | undefined; + }, +) { + // + const mutation = iterationMutationFromResult(args.result); + if (!mutation) return undefined; + + const action = await ctx.db.get(args.actionId); + if (!action || action.owner !== args.owner || action.skillKey !== 'iterate') throw NotFound(); + + const file = await ensureFileOwner(ctx, { + fileId: action.file, + owner: args.owner, + }); + + if (mutation.body !== undefined) { + const current = await renderCurrentContent(ctx, { file }); + const body = sanitizeInternalIdLeaks({ + text: mutation.body, + allowedInternalIdText: current, + }); + if (body.trim() && current !== body) { + await writeFileContent(ctx, { + fileId: action.file, + owner: args.owner, + author: args.actionId, + content: body, + shouldCreateAction: false, + }); + } + } + + if (mutation.tags.length > 0) { + await setFileTags(ctx, { + owner: args.owner, + file: action.file, + author: args.actionId, + tags: mutation.tags, + shouldCreateAction: false, + actionSkill: 'iterate', + }); + } +} + +function planMutationFromResult(result: z.infer | undefined) { + // + const parsed = planMutationSchema.safeParse(result?.metadata?.['planMutation']); + return parsed.success ? parsed.data : undefined; +} + +function iterationMutationFromResult(result: z.infer | undefined) { + // + const parsed = iterationMutationSchema.safeParse(result?.metadata?.['iterationMutation']); + return parsed.success ? parsed.data : undefined; +} + +function sanitizeInternalIdLeaks(args: { text: string; allowedInternalIdText: string }) { + // + const blockedIds = internalIdsIn(args.text).filter((id) => !args.allowedInternalIdText.includes(id)); + if (blockedIds.length === 0) return args.text; + + const blocked = new Set(blockedIds); + const lines = args.text.split('\n').filter((line) => { + for (const id of blocked) { + if (line.includes(id)) return false; + } + + return true; + }); + + return lines.join('\n').trim(); +} + +function internalIdsIn(text: string) { + // + return Array.from(new Set(text.match(/\b[a-z][a-z0-9]{24,}\b/g) ?? [])); +} + +export const _recordSandboxOutputs = internalMutation({ + args: { + owner: zid('users'), + actionId: zid('actions'), + outputs: z.array(recordedSandboxOutputSchema), + }, + handler: async (ctx, { owner, actionId, outputs }) => { + // + const action = await ctx.db.get(actionId); + if (!action || action.owner !== owner) throw NotFound(); + + const files = []; + + for (const output of outputs) { + const name = await uniqueOutputName(ctx, { + owner, + parent: action.file, + path: output.path, + }); + const file = await createFile(ctx, { + owner, + parent: action.file, + name, + author: actionId, + content: 'content' in output ? output.content : undefined, + tags: [ + { key: 'kind', value: 'artifact' }, + { key: 'source', value: 'sandbox' }, + ], + shouldAddInboxTag: false, + shouldCreateAction: false, + }); + if ('pointer' in output) { + await ctx.db.patch(file, { + currentContent: output.pointer, + updatedAt: Date.now(), + }); + } + + files.push( + actionResultFileSchema.parse({ + file, + path: output.path, + size: output.size, + contentType: output.contentType, + }), + ); + } + + return { + files, + }; + }, +}); + +async function uniqueOutputName( + ctx: Parameters[0], + args: { + owner: Parameters[1]['owner']; + parent: Parameters[1]['parent']; + path: string; + }, +) { + // + const name = outputName(args.path); + let candidate = name; + let suffix = 2; + + while ( + await findChildByName(ctx, { + owner: args.owner, + parent: args.parent, + name: candidate, + }) + ) { + candidate = withNumericSuffix(name, suffix); + suffix += 1; + } + + return candidate; +} + +function outputName(path: string) { + // + const parts = path.split('/'); + + for (let index = parts.length - 1; index >= 0; index -= 1) { + const part = parts[index]; + if (part) return part; + } + + return 'output.txt'; +} + +function withNumericSuffix(name: string, suffix: number) { + // + const dot = name.lastIndexOf('.'); + if (dot <= 0) return `${name}-${suffix}`; + + return `${name.slice(0, dot)}-${suffix}${name.slice(dot)}`; +} + +async function applySettledResultState( + ctx: MutationCtx, + args: { + owner: Id<'users'>; + actionId: Id<'actions'>; + result: z.infer | undefined; + }, +) { + // + if (args.result?.metadata?.['kind'] !== 'seek') return; + if (args.result.metadata['seekState'] !== 'done') return; + + const action = await ctx.db.get(args.actionId); + if (!action || action.owner !== args.owner) throw NotFound(); + + await setFileTags(ctx, { + owner: args.owner, + file: action.file, + author: args.actionId, + tags: [{ key: 'status', value: 'done' }], + shouldCreateAction: false, + actionSkill: 'iterate', + }); +} + +function shouldReleaseAvailableBudgetOnSettle(result: z.infer | undefined) { + // + return result?.metadata?.['kind'] === 'seek' && result.metadata['seekState'] === 'done'; +} 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..9f847a93 100644 --- a/apps/meseeks/convex/schema.ts +++ b/apps/meseeks/convex/schema.ts @@ -2,15 +2,19 @@ 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 { boxSchema } from 'schemas/boxSchema'; import { draftSchema } from 'schemas/draftSchema'; +import { fileContentSchema, fileSchema } from 'schemas/fileSchema'; +import { fileTagSchema } from 'schemas/fileTagSchema'; +import { loopSchema } from 'schemas/loopSchema'; import { polarEventSchema } from 'schemas/polarEventSchema'; -import { scheduleSchema } from 'schemas/scheduleSchema'; +import { readSchema } from 'schemas/readSchema'; +import { routeSchema } from 'schemas/routeSchema'; 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 { triggerSchema } from 'schemas/triggerSchema'; import { userPreferencesSchema, userRequestSchema, userSchema } from 'schemas/userSchema'; // oxfmt-ignore @@ -43,83 +47,139 @@ export default defineSchema({ drafts: defineTable( zodToConvex(draftSchema), ).index( - 'by_owner_taskId', ['owner', 'taskId'], + 'by_owner_fileId', ['owner', 'fileId'], ), - tasks: defineTable( - zodToConvex(taskSchema), + files: defineTable( + zodToConvex(fileSchema), ).index( - 'by_owner_parentId_isActive', ['owner', 'parentId', 'isActive'], + 'by_owner_parent_name', ['owner', 'parent', 'name'], ).index( - 'by_parent_isActive', ['parentId', 'isActive'], + 'by_parent', ['parent'], ).index( - 'by_owner_isActive', ['owner', 'isActive'], + 'by_owner', ['owner'], + ), + + file_contents: defineTable( + zodToConvex(fileContentSchema), ).index( - 'by_owner_status', ['owner', 'status'], + 'by_file', ['file'], ).index( - 'by_owner_energyAvailable', ['owner', 'energyBudget.available'], + 'by_owner_file', ['owner', 'file'], + ), + + file_tags: defineTable( + zodToConvex(fileTagSchema), + ).index( + 'by_file_key', ['file', 'key'], + ).index( + 'by_owner_key_value', ['owner', 'key', 'value'], + ).index( + 'by_owner_key', ['owner', 'key'], + ).index( + 'by_file', ['file'], + ), + + skills: defineTable( + zodToConvex(skillSchema), + ).index( + 'by_owner_key', ['owner', 'key'], + ).index( + 'by_owner_public_key', ['owner', 'isPublic', 'key'], + ).index( + 'by_public_key', ['isPublic', 'key'], + ).index( + 'by_owner_kind', ['owner', 'kind'], + ).index( + 'by_file', ['file'], + ).index( + 'by_source_owner_key', ['sourceOwner', 'sourceKey'], ), - // .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_file_index', ['file', 'index'], ).index( - 'by_task_status', ['taskId', 'status'], + 'by_file_status', ['file', 'status'], ).index( - 'by_task_author_status', ['taskId', 'author', 'status'], + 'by_file_spark', ['file', 'spark'], ).index( 'by_status', ['status'], + ).index( + 'by_author', ['author'], ), - action_details: defineTable( + details: defineTable( zodToConvex(actionDetailSchema), ).index( - 'by_action', ['actionId'], + 'by_action', ['action'], + ).index( + 'by_skill', ['skill'], ), - schedules: defineTable( - zodToConvex(scheduleSchema), + triggers: defineTable( + zodToConvex(triggerSchema), ).index( - 'by_task', ['taskId'], + 'by_file', ['file'], ).index( - 'by_owner', ['owner'], + 'by_loop', ['loop'], + ).index( + 'by_author', ['author'], + ).index( + 'by_handler', ['handler'], ), - skills: defineTable( - zodToConvex(skillSchema), - ).index( - 'by_owner_kind', ['owner', 'kind'], + loops: defineTable( + zodToConvex(loopSchema), ).index( 'by_owner_key', ['owner', 'key'], + ).index( + 'by_owner_public_key', ['owner', 'isPublic', 'key'], + ).index( + 'by_public_key', ['isPublic', 'key'], + ).index( + 'by_source_owner_key', ['sourceOwner', 'sourceKey'], ), - components: defineTable( - zodToConvex(componentSchema), + reads: defineTable( + zodToConvex(readSchema), + ).index( + 'by_user_file', ['user', 'file'], + ).index( + 'by_file', ['file'], + ), + + routes: defineTable( + zodToConvex(routeSchema), ).index( 'by_owner_slug', ['owner', 'slug'], + ).index( + 'by_owner_public_slug', ['owner', 'isPublic', 'slug'], + ).index( + 'by_public_slug', ['isPublic', 'slug'], + ).index( + 'by_file', ['file'], + ), + + boxes: defineTable( + zodToConvex(boxSchema), + ).index( + 'by_owner_file', ['owner', 'file'], + ).index( + 'by_file', ['file'], + ).index( + 'by_status', ['status'], ), transactions: defineTable( zodToConvex(transactionSchema), ).index( 'by_owner', ['owner'], - ).searchIndex( - 'search_transactions', { - searchField: 'description', - filterFields: ['owner', 'kind'], - } + ).index( + 'by_file', ['file'], + ).index( + 'by_action', ['action'], ), subscriptions: defineTable( diff --git a/apps/meseeks/convex/seed.ts b/apps/meseeks/convex/seed.ts index 949ed07b..c6187d03 100644 --- a/apps/meseeks/convex/seed.ts +++ b/apps/meseeks/convex/seed.ts @@ -1,167 +1,78 @@ import { z } from 'zod/v3'; +import { zid } from 'convex-helpers/server/zod3'; import { internalMutation } from 'lib/convex'; -import { asBigInt } from 'lib/money'; -import { zodToString } from 'lib/zodToString'; -import { skillSchema } from 'schemas/skillSchema'; +import { recommendedIntelligences } from 'lib/proDefinitions'; +import { env } from 'schemas/envSchema'; +import { configuredProOwner } from './proOwner.private'; +import { seedUserIfNeeded, syncProDefinitions } from './users.private'; 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); + const proOwner = configuredProOwner() ?? (await previewProOwner(ctx)); + if (!proOwner) { + return { + intelligences: recommendedIntelligences.map((intelligence) => intelligence.key), + managedCatalog: 'app-local', + proDefinitions: { + status: 'not-configured', + }, + }; + } - return { components, skills }; + try { + await seedUserIfNeeded(ctx, { userId: proOwner }); + const proDefinitions = await syncProDefinitions(ctx, { owner: proOwner }); + + return { + intelligences: recommendedIntelligences.map((intelligence) => intelligence.key), + managedCatalog: 'app-local', + proDefinitions, + }; + } catch (error) { + console.warn('pro definitions seed skipped', { + proOwner, + error, + }); + + return { + intelligences: recommendedIntelligences.map((intelligence) => intelligence.key), + managedCatalog: 'app-local', + proDefinitions: { + status: 'missing-owner', + owner: proOwner, + }, + }; + } }, }); -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 }; -} +export const _syncPro = internalMutation({ + args: z.object({ + owner: zid('users'), + }), + handler: syncProDefinitions, +}); -async function seedSkills(ctx: MutationCtx) { +async function previewProOwner(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 }; + if (env.ENV_TYPE === 'production') return undefined; + + const email = `seed+${env.ENV_TYPE}@pro.local`; + const existing = await ctx.db + .query('users') + .withIndex('email', (q) => q.eq('email', email)) + .unique(); + if (existing) return existing._id; + + return await ctx.db.insert('users', { + email, + name: 'PRO', + isReady: false, + balanceUSD: 0n, + committedBudgetUSD: 0n, + isFounder: false, + }); } diff --git a/apps/meseeks/convex/skills.private.ts b/apps/meseeks/convex/skills.private.ts index 691c0b64..6c43bec8 100644 --- a/apps/meseeks/convex/skills.private.ts +++ b/apps/meseeks/convex/skills.private.ts @@ -1,429 +1,491 @@ import { zid } from 'convex-helpers/server/zod3'; import { z } from 'zod/v3'; 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, - 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, - }); -} +import { managedSkills } from 'lib/proDefinitions'; +import { instinctSkills, referenceInstinctSkill } from 'lib/reactor/instincts'; +import { authorSchema } from 'schemas/authorSchema'; +import { intelligenceKeys } from 'schemas/intelligenceSchema'; +import { requestHeaderSchema, skillInputArgumentSchema } from 'schemas/skillSchema'; +import type { Doc, Id } from './_generated/dataModel'; +import type { MutationCtx, QueryCtx } from './_generated/server'; +import { catFile, createFile, findChildByName, writeFileContent } from './files.private'; +import { recordMutationAction } from './reactor.private'; + +const createSkillCoreArgsSchema = z.object({ + owner: zid('users'), + key: z.string().min(1), + name: z.string().min(1), + description: z.string().default(''), + input: z.array(skillInputArgumentSchema).optional(), + file: zid('files'), + isPublic: z.boolean().optional(), + sourceOwner: zid('users').optional(), + sourceKey: z.string().min(1).optional(), + sourceFile: zid('files').optional(), + author: authorSchema, +}); -export function isBuiltInSkillKey( - skillKey: string, // -): skillKey is keyof typeof _builtInSkills { - // - return skillKey in _builtInSkills; -} +const createSkillArgsSchema = z.union([ + createSkillCoreArgsSchema.extend({ + kind: z.literal('think'), + intelligence: z.union([z.literal('auto'), intelligenceKeys]).default('auto'), + temperature: z.number().min(0).max(2).optional(), + toolPolicy: z + .object({ + skillKeys: z.array(z.string().min(1)).default([]), + includeFileSkills: z.boolean().default(true), + }) + .optional(), + }), + createSkillCoreArgsSchema.extend({ + kind: z.literal('request'), + method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']), + url: z.string().min(1), + headers: z.array(requestHeaderSchema).default([]), + }), + createSkillCoreArgsSchema.extend({ + kind: z.literal('execute'), + command: z.string().min(1), + timeoutMs: z.number().int().positive().optional(), + env: z.record(z.string()).optional(), + }), +]); -export const ensureSkillOwner = defineQuery({ +export const findSkills = defineQuery({ args: z.object({ - skillId: zid('skills'), + owner: zid('users'), }), - handler: async (ctx, { skillId }) => { + handler: async (ctx, { owner }) => { // - const currentUser = await getCurrentUser(ctx, {}); - const skill = await ctx.db.get(skillId); + const ownerSkills = await ctx.db + .query('skills') + .withIndex('by_owner_key', (q) => q.eq('owner', owner)) + .collect(); + const proSkills = await publicProSkills(ctx, { owner }); + const keys = new Set(ownerSkills.map((skill) => skill.key)); + const visible = ownerSkills.slice(); - if (!skill) throw NotFound(); - if (skill.owner !== currentUser._id) throw NotFound(); // purposefully do not mention authorization + for (const skill of proSkills) { + if (keys.has(skill.key)) continue; + visible.push(skill); + } - return { currentUser, skill }; + return visible; }, }); -const getUserPreference = defineQuery({ +export const findSkillById = defineQuery({ args: z.object({ - userId: zid('users'), - key: z.string(), + owner: zid('users'), + skill: zid('skills'), }), - handler: async (ctx, { userId, key }) => { + handler: async (ctx, { owner, skill }) => { // - return await ctx.db - .query('user_preferences') - .withIndex('by_owner_key', (q) => q.eq('owner', userId).eq('key', key)) - .unique(); + const row = await ctx.db.get(skill); + if (!row || !isSkillVisibleToOwner({ skill: row, owner })) return undefined; + + return row; }, }); -const setUserPreference = defineMutation({ +export const findSkillByFile = defineQuery({ args: z.object({ - userId: zid('users'), - key: z.string(), - value: z.unknown(), + owner: zid('users'), + file: zid('files'), }), - handler: async (ctx, { userId, key, value }) => { + handler: async (ctx, { owner, file }) => { // - const preference = await getUserPreference(ctx, { userId, key }); + const row = await ctx.db + .query('skills') + .withIndex('by_file', (q) => q.eq('file', file)) + .unique(); + if (!row || !isSkillVisibleToOwner({ skill: row, owner })) return undefined; - if (!preference) { - await ctx.db.insert('user_preferences', { owner: userId, key, value }); - } else { - await ctx.db.patch(preference._id, { value }); - } + return row; }, }); -export const findEnabledSkillKeys = defineQuery({ +export const findSkillByKey = defineQuery({ args: z.object({ - userId: zid('users'), + owner: zid('users'), + key: z.string().min(1), }), - handler: async (ctx, { userId }) => { - // - const enabledSkills = await getUserPreference(ctx, { - key: 'enabledSkills', - userId, - }); - - return Array.isArray(enabledSkills?.value) ? enabledSkills.value.filter(isString) : []; - }, + handler: async (ctx, { owner, key }) => + await ctx.db + .query('skills') + .withIndex('by_owner_key', (q) => q.eq('owner', owner).eq('key', key)) + .unique(), }); -export const findAllSkillKeys = defineQuery({ +export const findSkillWithBody = defineQuery({ args: z.object({ - userId: zid('users'), + owner: zid('users'), + skill: zid('skills'), }), - handler: async (ctx, { userId }) => { + handler: async (ctx, { owner, skill }) => { // - const dbList = await findAllSkills(ctx, { owner: userId }).then((list) => - list.map((skill) => ({ - key: skill.key, - description: skill.description, - })), - ); + const row = await findSkillById(ctx, { owner, skill }); + if (!row) return undefined; - const builtInList = Object.entries(_builtInSkills).map(([key, builtInTool]) => ({ - key, - description: builtInTool.description, - })); - - return dbList.concat(builtInList); + return await withBody(ctx, { skill: row }); }, }); -export const findEnabledSkillsWithDetails = defineQuery({ +export const findSkillWithBodyByRouteId = defineQuery({ args: z.object({ - userId: zid('users'), + owner: zid('users'), + id: z.string().min(1), }), - handler: async (ctx, { userId }) => { + handler: async (ctx, { owner, id }) => { // - 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, - }); - } - - // Add built-in skills - for (const [key, builtInTool] of Object.entries(_builtInSkills)) { - allSkillsMap.set(key, { - key, - description: builtInTool.description, - inputSchema: zodToString(builtInTool.parameters), - }); + const skillId = ctx.db.normalizeId('skills', id); + if (skillId) { + const row = await findSkillById(ctx, { owner, skill: skillId }); + if (row) return await withBody(ctx, { skill: row }); } - // Filter to only enabled skills - const enabledSkillsSet = new Set(enabledSkillKeys); - const skillDetails = []; + const fileId = ctx.db.normalizeId('files', id); + if (!fileId) return undefined; - for (const [key, skill] of allSkillsMap) { - if (enabledSkillsSet.has(key)) { - skillDetails.push(skill); - } - } + const row = await findSkillByFile(ctx, { owner, file: fileId }); + if (!row) return undefined; - return skillDetails; + return await withBody(ctx, { skill: row }); }, }); -export const createSkill = defineMutation({ +export const resolveSkillForRuntime = defineQuery({ args: z.object({ - skill: newSkillSchema, - userId: zid('users'), + owner: zid('users'), + skillKey: z.string().min(1), }), - handler: async (ctx, { skill, userId }) => { + handler: async (ctx, { owner, skillKey }) => { // - // TODO: bad logic, improve this - const existing = await findSkillSafe(ctx, { key: skill.key, owner: userId }); - if (existing) throw new Error(`Skill key '${skill.key}' in use.`); + const instinct = referenceInstinctSkill(skillKey); + if (instinct) { + return { + key: instinct.key, + name: instinct.name, + description: instinct.description, + kind: 'instinct', + body: instinct.body, + }; + } - ensureInputSchemaIsValid(skill.inputSchema); + const row = await findSkillByKey(ctx, { owner, key: skillKey }); + if (row) return await withBody(ctx, { skill: row }); - return await ctx.db.insert('skills', { - ...skill, - owner: userId, - author: userId, - }); + const publicSkill = await publicProSkillByKey(ctx, { owner, key: skillKey }); + if (publicSkill) return await withBody(ctx, { skill: publicSkill }); + + return undefined; }, }); -export const updateSkill = defineMutation({ +export const seedManagedSkills = defineMutation({ args: z.object({ - skill: newSkillSchema, - userId: zid('users'), + owner: zid('users'), + author: authorSchema, + parent: zid('files'), }), - handler: async (ctx, { skill, userId }) => { + handler: async (ctx, { owner, author, parent }) => { // - 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, + const existingSkillsDir = await findChildByName(ctx, { + owner, + parent, + name: 'skills', }); + const skillsDirId = + existingSkillsDir?._id ?? + (await createFile(ctx, { + owner, + parent, + name: 'skills', + author, + tags: [{ key: 'kind', value: 'directory' }], + shouldAddInboxTag: false, + })); + const skillIds: Id<'skills'>[] = []; + + for (const skill of managedSkills) { + const fileId = await upsertManagedSkillFile(ctx, { + owner, + author, + parent: skillsDirId, + key: skill.key, + body: skill.body, + }); + const rowId = await upsertSkill(ctx, { + owner, + key: skill.key, + name: skill.name, + description: skill.description, + kind: skill.kind, + input: skill.input, + file: fileId, + intelligence: 'auto', + isPublic: true, + author, + }); + skillIds.push(rowId); + } + + return skillIds; }, }); +export const createSkill = defineMutation({ + args: createSkillArgsSchema, + handler: async (ctx, args) => await upsertSkill(ctx, args), +}); + export const enableSkill = defineMutation({ args: z.object({ userId: zid('users'), - skillKey: z.string(), + skillKey: z.string().min(1), }), handler: async (ctx, { userId, skillKey }) => { // - const enabledSkills = await getUserPreference(ctx, { - userId, - key: 'enabledSkills', - }); + const existing = await findSkillByKey(ctx, { owner: userId, key: skillKey }); + if (existing) return existing._id; - const currentSkills = Array.isArray(enabledSkills?.value) ? enabledSkills.value.filter(isString) : []; - if (currentSkills.includes(skillKey)) return; + const file = await createFile(ctx, { + owner: userId, + name: `${skillKey}.skill.md`, + author: userId, + content: '', + tags: [{ key: 'kind', value: 'skill-source' }], + shouldAddInboxTag: false, + }); - await setUserPreference(ctx, { - userId, - key: 'enabledSkills', - value: currentSkills.concat(skillKey), + return await upsertSkill(ctx, { + owner: userId, + key: skillKey, + name: skillKey, + description: '', + kind: 'think', + file, + intelligence: 'auto', + author: userId, }); }, }); -export const replaceProSkills = defineMutation({ - args: z.object({ - skills: z.array(z.unknown()), - deleteUnspecified: z.boolean().optional().default(false), - }), - 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); - }); - - console.info(`Replacing Pro-managed skills (deleteUnspecified: ${deleteUnspecified})`); - - // Validate all skills have the correct owner - for (const skill of skills) { - if (skill.owner !== 'isPro') { - throw new Error(`All skills must have owner "isPro", found: ${skill.owner}`); - } - } +async function upsertManagedSkillFile( + ctx: MutationCtx, + args: { + owner: Id<'users'>; + author: z.infer; + parent: Id<'files'>; + key: string; + body: string; + }, +) { + // + const name = `${args.key}.skill.md`; + const existing = await findChildByName(ctx, { + owner: args.owner, + parent: args.parent, + name, + }); - // 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}`); - } - newSkillKeys.add(skill.key); + if (existing) { + if (existing.isPublic !== true) { + await ctx.db.patch(existing._id, { + isPublic: true, + updatedAt: Date.now(), + }); } - 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 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}`); - } + const previous = await catFile(ctx, { fileId: existing._id, owner: args.owner }); + if (previous !== args.body) { + await writeFileContent(ctx, { + owner: args.owner, + fileId: existing._id, + author: args.author, + content: args.body, + }); } - // 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.`, - ); - } - } - } + return existing._id; + } + + return await createFile(ctx, { + owner: args.owner, + parent: args.parent, + name, + author: args.author, + content: args.body, + isPublic: true, + tags: [{ key: 'kind', value: 'skill-source' }], + shouldAddInboxTag: false, + }); +} + +async function upsertSkill(ctx: MutationCtx, args: z.infer) { + // + const now = Date.now(); + const existing = await findSkillByKey(ctx, { owner: args.owner, key: args.key }); + const value = skillRowValue(args, now); + + if (existing) { + await ctx.db.patch(existing._id, value); + await recordMutationAction(ctx, { + owner: args.owner, + file: args.file, + author: args.author, + skillKey: 'updateSkill', + args: { + skill: existing._id, + key: args.key, + }, + result: { + text: `Updated skill ${args.key}.`, + files: [ + { + file: args.file, + path: args.name, + }, + ], + }, + }); + return existing._id; + } - console.info( - `Operation completed: ${insertedCount} inserted, ${updatedCount} updated, ${deletedCount} deleted`, - ); + const skillId = await ctx.db.insert('skills', { + ...value, + createdAt: now, + }); + await recordMutationAction(ctx, { + owner: args.owner, + file: args.file, + author: args.author, + skillKey: 'createSkill', + args: { + skill: skillId, + key: args.key, + }, + result: { + text: `Created skill ${args.key}.`, + files: [ + { + file: args.file, + path: args.name, + }, + ], + }, + }); + return skillId; +} + +function skillRowValue(args: z.infer, updatedAt: number) { + // + const core = { + owner: args.owner, + key: args.key, + name: args.name, + description: args.description, + input: args.input, + file: args.file, + isPublic: args.isPublic, + sourceOwner: args.sourceOwner, + sourceKey: args.sourceKey, + sourceFile: args.sourceFile, + author: args.author, + updatedAt, + }; + + if (args.kind === 'think') { return { - success: true, - inserted: insertedCount, - updated: updatedCount, - deleted: deletedCount, - skillKeys: skills.map((skill) => skill.key), - deletedUnspecified: deleteUnspecified, + ...core, + kind: args.kind, + intelligence: args.intelligence, + temperature: args.temperature, + toolPolicy: args.toolPolicy, }; - }, -}); + } -export const findAllSkillsByOwner = defineQuery({ - args: z.object({ - owner: skillOwnerSchema, - kind: simplifiedSkillKindSchema.optional(), - }), - handler: async (ctx, { owner, kind }) => { - // - return await ctx.db - .query('skills') - .withIndex('by_owner_kind', (q) => - kind - ? q.eq('owner', owner).eq('kind', kind) // - : q.eq('owner', owner), - ) - .collect(); - }, -}); + if (args.kind === 'request') { + return { + ...core, + kind: args.kind, + method: args.method, + url: args.url, + headers: args.headers, + }; + } + + return { + ...core, + kind: args.kind, + command: args.command, + timeoutMs: args.timeoutMs, + env: args.env, + }; +} -export const findSkillByOwner = defineQuery({ - args: z.object({ - key: z.string(), - owner: skillOwnerSchema, - }), - handler: async (ctx, { key, owner }) => { - // - return await ctx.db - .query('skills') - .withIndex('by_owner_key', (q) => q.eq('owner', owner).eq('key', key)) - .unique(); - }, -}); +export function listInstinctSkills() { + // + return instinctSkills.map((skill) => ({ + key: skill.key, + name: skill.name, + description: skill.description, + kind: 'instinct', + input: skill.input, + body: skill.body, + })); +} -export const findAllSkills = defineQuery({ - args: z.object({ - owner: zid('users'), - kind: simplifiedSkillKindSchema.optional(), - }), - handler: async (ctx, { owner, kind }) => { - // - const [globals, users] = await Promise.all([ - findAllSkillsByOwner(ctx, { owner: 'isPro', kind }), // global skills - findAllSkillsByOwner(ctx, { owner, kind }), // user-defined skills - ]); +async function publicProSkills(ctx: QueryCtx, args: { owner: Id<'users'> }) { + // + const skills = await ctx.db + .query('skills') + .withIndex('by_public_key', (q) => q.eq('isPublic', true)) + .collect(); - return globals.concat(users); - }, -}); + return dedupePublicSkills(skills).filter((skill) => skill.owner !== args.owner); +} -export const findSkill = defineQuery({ - args: z.object({ - key: z.string(), - owner: zid('users'), - }), - handler: async (ctx, { key, owner }) => { - // - const globalSkill = await findSkillByOwner(ctx, { key, owner: 'isPro' }); - if (globalSkill) return globalSkill; - - const userSkill = await findSkillByOwner(ctx, { key, owner }); - if (userSkill) return userSkill; - - 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, - }); - } +async function publicProSkillByKey(ctx: QueryCtx, args: { owner: Id<'users'>; key: string }) { + // + const skills = await ctx.db + .query('skills') + .withIndex('by_public_key', (q) => q.eq('isPublic', true).eq('key', args.key)) + .collect(); - throw new Error(`Unknown skill: ${key}`); - }, -}); + return dedupePublicSkills(skills).find((skill) => skill.owner !== args.owner); +} -export const findSkillSafe = defineQuery({ - args: z.object({ - key: z.string(), - owner: zid('users'), - }), - handler: async (ctx, { key, owner }) => { - // - try { - return await findSkill(ctx, { key, owner }); - } catch (error) { - if (error instanceof Error && error.message === `Unknown skill: ${key}`) { - return undefined; - } - throw error; - } - }, -}); +function isSkillVisibleToOwner(args: { skill: Doc<'skills'>; owner: Id<'users'> }) { + // + if (args.skill.owner === args.owner) return true; + + return args.skill.isPublic === true; +} + +function dedupePublicSkills(skills: Doc<'skills'>[]) { + // + const byKey = new Map>(); + const ordered = skills + .slice() + .sort((left, right) => right.updatedAt - left.updatedAt || right._creationTime - left._creationTime); + + for (const skill of ordered) { + if (byKey.has(skill.key)) continue; + byKey.set(skill.key, skill); + } + + return Array.from(byKey.values()); +} + +async function withBody(ctx: QueryCtx, args: { skill: Doc<'skills'> }) { + // + const file = await ctx.db.get(args.skill.file); + + return { + ...args.skill, + fileName: file?.name, + input: args.skill.input ?? [], + body: await catFile(ctx, { fileId: args.skill.file, owner: args.skill.owner }), + }; +} diff --git a/apps/meseeks/convex/skills.ts b/apps/meseeks/convex/skills.ts index 5c05f284..8c7636d5 100644 --- a/apps/meseeks/convex/skills.ts +++ b/apps/meseeks/convex/skills.ts @@ -1,189 +1,125 @@ -import { zid } from 'convex-helpers/server/zod3'; import { z } from 'zod/v3'; -import { internalMutation, internalQuery, mutation, query } from 'lib/convex'; +import { mutation, query } from 'lib/convex'; import { NotFound } from 'lib/errors'; -import { newSkillSchema, simplifiedSkillKindSchema } from 'schemas/skillSchema'; -import { - buildInSkillToDoc, - createSkill, - enableSkill, - findAllSkills, - findAllSkillsByOwner, - findAllSkillKeys, - findEnabledSkillsWithDetails, - findSkill, - isBuiltInSkillKey, - replaceProSkills, - updateSkill, -} from './skills.private'; -import { _builtInSkills } from 'skills/builtIn/index'; +import { referenceInstinctSkill } from 'lib/reactor/instincts'; +import { requestHeaderSchema, skillInputArgumentSchema, storedSkillKindSchema } from 'schemas/skillSchema'; +import { createFile } from './files.private'; +import { createSkill, findSkillWithBodyByRouteId, findSkills, listInstinctSkills } from './skills.private'; import { getCurrentUser } from './users.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.'), - }, - 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(), - owner: zid('users'), - }, - handler: findSkill, -}); - -// used by magicRock.private.ts to expand {{allSkills}} in system instructions -export const _findAllKeys = internalQuery({ - args: { - userId: zid('users'), - }, - handler: findAllSkillKeys, -}); - -// used by magicRock.private.ts to expand {{activeSkills}} with enabled skill schemas -export const _findEnabledSkillsWithDetails = internalQuery({ - args: { - userId: zid('users'), - }, - handler: findEnabledSkillsWithDetails, -}); - -// 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, -}); - -export const findAllPublic = query({ - args: {}, - handler: async (ctx) => { - // - 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; - }, -}); - -export const findAllPersonal = query({ +export const findAll = query({ args: {}, handler: async (ctx) => { // const currentUser = await getCurrentUser(ctx, {}); - return await findAllSkillsByOwner(ctx, { owner: currentUser._id }); - }, -}); - -export const findAllInnate = 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 { + instincts: listInstinctSkills(), + skills: await findSkills(ctx, { owner: currentUser._id }), + }; }, }); -export const findOneInnate = query({ +export const findOne = query({ args: { - skillKey: z.string(), + id: z.string().min(1), }, - handler: async (ctx, { skillKey }) => { + handler: async (ctx, { id }) => { // - if (!isBuiltInSkillKey(skillKey)) throw NotFound(); - - const skill = _builtInSkills[skillKey]; + const currentUser = await getCurrentUser(ctx, {}); + const row = await findSkillWithBodyByRouteId(ctx, { owner: currentUser._id, id }); + if (!row) throw NotFound(); - return buildInSkillToDoc(skillKey, skill); + return row; }, }); -export const findOne = query({ +export const findInstinct = query({ args: { - skillId: zid('skills'), + key: z.string().min(1), }, - handler: async (ctx, { skillId }) => { + handler: async (ctx, { key }) => { // - const currentUser = await getCurrentUser(ctx, {}); - const skill = await ctx.db.get(skillId); + await getCurrentUser(ctx, {}); + const instinct = referenceInstinctSkill(key); + if (!instinct) throw NotFound(); - 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 = {}; - } - - return { - ...skill, - isEditable: skill.owner === currentUser._id, - }; + return instinct; }, }); export const create = mutation({ args: { - skill: newSkillSchema, - }, - handler: async (ctx, { skill }) => { + key: z.string().min(1), + name: z.string().min(1), + description: z.string().default(''), + kind: storedSkillKindSchema.default('think'), + input: z.array(skillInputArgumentSchema).default([]), + body: z.string().default(''), + method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).optional(), + url: z.string().min(1).optional(), + headers: z.array(requestHeaderSchema).default([]), + command: z.string().min(1).optional(), + timeoutMs: z.number().int().positive().optional(), + env: z.record(z.string()).optional(), + }, + handler: async ( + ctx, + { key, name, description, kind, input, body, method, url, headers, command, timeoutMs, env }, + ) => { // const currentUser = await getCurrentUser(ctx, {}); - return await createSkill(ctx, { skill, userId: currentUser._id }); - }, -}); + const file = await createFile(ctx, { + owner: currentUser._id, + name: `${key}.skill.md`, + author: currentUser._id, + content: body, + tags: [{ key: 'kind', value: 'skill-source' }], + shouldAddInboxTag: false, + }); + + if (kind === 'request') { + if (!method || !url) throw new Error('Request skills require method and URL.'); + return await createSkill(ctx, { + owner: currentUser._id, + key, + name, + description, + kind, + input, + file, + method, + url, + headers, + author: currentUser._id, + }); + } -export const update = mutation({ - args: { - skill: newSkillSchema, - }, - handler: async (ctx, { skill }) => { - // - const currentUser = await getCurrentUser(ctx, {}); - return await updateSkill(ctx, { skill, userId: currentUser._id }); + if (kind === 'execute') { + if (!command) throw new Error('Execute skills require a command.'); + return await createSkill(ctx, { + owner: currentUser._id, + key, + name, + description, + kind, + input, + file, + command, + timeoutMs, + env, + author: currentUser._id, + }); + } + + return await createSkill(ctx, { + owner: currentUser._id, + key, + name, + description, + kind, + input, + file, + author: currentUser._id, + }); }, }); diff --git a/apps/meseeks/convex/subscriptions.private.ts b/apps/meseeks/convex/subscriptions.private.ts index e9d0b31a..a8cf7548 100644 --- a/apps/meseeks/convex/subscriptions.private.ts +++ b/apps/meseeks/convex/subscriptions.private.ts @@ -103,12 +103,17 @@ export const findActiveSubscriptions = defineQuery({ handler: async (ctx, { owner }): Promise[]> => { // const now = Date.now(); - - return await ctx.db + const activeSubscriptions = 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)); + .collect(); + const validSubscriptions: Doc<'subscriptions'>[] = []; + + for (const subscription of activeSubscriptions) { + if ((subscription.validUntil ?? 0) > now) validSubscriptions.push(subscription); + } + + return validSubscriptions; }, }); 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/transactions.private.ts b/apps/meseeks/convex/transactions.private.ts index 112d69cb..e5f197ab 100644 --- a/apps/meseeks/convex/transactions.private.ts +++ b/apps/meseeks/convex/transactions.private.ts @@ -1,8 +1,8 @@ import { zid } from 'convex-helpers/server/zod3'; import { z } from 'zod/v3'; import { defineMutation } from 'lib/convex'; -import { transactionSchema, valueSchema } from 'schemas/transactionSchema'; -import { adjustUserBalance } from './users.private'; +import { NotFound } from 'lib/errors'; +import { newTransactionSchema, valueSchema } from 'schemas/transactionSchema'; export const addFreeCredits = defineMutation({ args: z.object({ @@ -20,27 +20,24 @@ export const addTopUpTransaction = defineMutation({ owner: zid('users'), description: z.string().optional(), }), - handler: async (ctx, args) => addTransaction(ctx, { kind: 'top up', ...args }), + handler: async (ctx, { topUpId, ...args }) => + addTransaction(ctx, { + kind: 'top up', + ...args, + topUpId, + description: args.description ?? `Top up ${topUpId}`, + }), }); -export const addTaskFundingTransaction = defineMutation({ +export const addSettlementTransaction = defineMutation({ args: z.object({ value: valueSchema, - taskId: zid('tasks'), + file: zid('files'), + action: zid('actions'), owner: zid('users'), - description: z.string().optional(), - }), - handler: async (ctx, args) => addTransaction(ctx, { kind: 'fund task', ...args }), -}); - -export const addTaskRefundTransaction = defineMutation({ - args: z.object({ - value: valueSchema, - taskId: zid('tasks'), - owner: zid('users'), - description: z.string().optional(), + description: z.string(), }), - handler: async (ctx, args) => addTransaction(ctx, { kind: 'refund from task', ...args }), + handler: async (ctx, args) => addTransaction(ctx, { kind: 'action settlement', ...args }), }); export const addSubscriptionCreditsTransaction = defineMutation({ @@ -50,19 +47,31 @@ export const addSubscriptionCreditsTransaction = defineMutation({ owner: zid('users'), description: z.string().optional(), }), - handler: async (ctx, args) => addTransaction(ctx, { kind: 'subscription', ...args }), + handler: async (ctx, { subscriptionId, ...args }) => + addTransaction(ctx, { + kind: 'subscription', + ...args, + subscriptionId, + description: args.description ?? `Subscription credits ${subscriptionId}`, + }), }); // helper, not exported const addTransaction = defineMutation({ - args: transactionSchema, - handler: async (ctx, transaction) => { + args: newTransactionSchema, + handler: async (ctx, args) => { // - const transactionId = await ctx.db.insert('transactions', transaction); + const transactionId = await ctx.db.insert('transactions', { + ...args, + createdAt: Date.now(), + }); + + const user = await ctx.db.get(args.owner); + if (!user) throw NotFound(); + if (args.value.symbol !== 'USD') throw new Error('Only USD is supported for now'); - await adjustUserBalance(ctx, { - userId: transaction.owner, - value: transaction.value, + await ctx.db.patch(args.owner, { + balanceUSD: (user.balanceUSD ?? 0n) + args.value.amount, }); return transactionId; diff --git a/apps/meseeks/convex/transactions.ts b/apps/meseeks/convex/transactions.ts index d10fb1df..e75dfdda 100644 --- a/apps/meseeks/convex/transactions.ts +++ b/apps/meseeks/convex/transactions.ts @@ -22,21 +22,10 @@ export const findAllPaginated = query({ paginationOpts: paginationOptionsSchema, search: z.string().optional(), }, - handler: async (ctx, { paginationOpts, search }) => { + handler: async (ctx, { paginationOpts }) => { // 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)) diff --git a/apps/meseeks/convex/triggerIsolate.ts b/apps/meseeks/convex/triggerIsolate.ts new file mode 100644 index 00000000..0db62c61 --- /dev/null +++ b/apps/meseeks/convex/triggerIsolate.ts @@ -0,0 +1,182 @@ +'use node'; + +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import { action, internalAction } from 'lib/convex'; +import { Unauthorized } from 'lib/errors'; +import { evaluateTriggerCode } from 'lib/triggerIsolate'; +import { internal } from './_generated/api'; +import type { Doc } from './_generated/dataModel'; +import type { ActionCtx } from './_generated/server'; + +const isolateTriggerSchema = z.object({ + trigger: zid('triggers'), + kind: z.enum(['file', 'loop']), + handler: zid('files'), + handlerCode: z.string().min(1), + maxUses: z.number().int().nonnegative(), + uses: z.number().int().nonnegative(), + file: zid('files').optional(), + loop: zid('loops').optional(), +}); + +const isolateContextSchema = z.object({ + action: z + .object({ + _id: zid('actions'), + file: zid('files'), + skillKey: z.string(), + args: z.record(z.unknown()), + resultFile: zid('files').optional(), + }) + .passthrough(), + triggers: z.array(isolateTriggerSchema), +}); + +const isolateProposalSchema = z.object({ + trigger: zid('triggers'), + skillKey: z.string().min(1), + args: z.record(z.unknown()).default({}), + loop: zid('loops').nullable().optional(), +}); + +type EvaluationResult = { + proposals: number; + accepted: number; +}; + +export const _evaluate = internalAction({ + args: { + owner: zid('users'), + actionId: zid('actions'), + }, + handler: async (ctx, { owner, actionId }): Promise => + await evaluateAndSchedule(ctx, { owner, actionId }), +}); + +export const evaluate = action({ + args: { + actionId: zid('actions'), + }, + handler: async (ctx, { actionId }) => { + // + const currentUser = await currentActionUser(ctx); + return await evaluateAndSchedule(ctx, { + owner: currentUser._id, + actionId, + }); + }, +}); + +async function evaluateAndSchedule( + ctx: ActionCtx, + args: { owner: Doc<'users'>['_id']; actionId: Doc<'actions'>['_id'] }, +): Promise { + // + const context = isolateContextSchema.parse( + await ctx.runQuery(internal.triggerIsolateState._context, { + owner: args.owner, + actionId: args.actionId, + }), + ); + const proposals = []; + + for (const trigger of context.triggers) { + const triggerProposals = await evaluateOneTriggerOrEmpty({ + trigger, + context: { + action: context.action, + trigger: publicTriggerContext(trigger), + }, + }); + proposals.push(...triggerProposals); + } + + const accepted = z + .number() + .int() + .nonnegative() + .parse( + await ctx.runMutation(internal.triggerIsolateState._acceptProposals, { + owner: args.owner, + source: { + kind: 'action', + actionId: args.actionId, + }, + proposals, + }), + ); + + return { + proposals: proposals.length, + accepted, + }; +} + +async function evaluateOneTriggerOrEmpty(args: { + trigger: z.infer; + context: Record; +}) { + // + try { + return await evaluateOneTrigger(args); + } catch (error) { + console.warn('trigger handler failed', { + trigger: args.trigger.trigger, + message: error instanceof Error ? error.message : 'unknown error', + }); + return []; + } +} + +async function evaluateOneTrigger(args: { + trigger: z.infer; + context: Record; +}) { + // + const evaluated = await evaluateTriggerCode({ + code: args.trigger.handlerCode, + context: args.context, + timeoutMs: 1000, + }); + const proposals = []; + + for (const proposal of evaluated.proposals) { + const parsedLoop = proposal.loop ? zid('loops').safeParse(proposal.loop) : undefined; + proposals.push( + isolateProposalSchema.parse({ + trigger: args.trigger.trigger, + skillKey: proposal.skillKey, + args: proposal.args, + loop: parsedLoop?.success ? parsedLoop.data : proposal.loop === null ? null : undefined, + }), + ); + } + + return proposals; +} + +function publicTriggerContext(trigger: z.infer) { + // + return { + id: trigger.trigger, + kind: trigger.kind, + handler: trigger.handler, + maxUses: trigger.maxUses, + uses: trigger.uses, + file: trigger.file, + loop: trigger.loop, + }; +} + +async function currentActionUser(ctx: ActionCtx): Promise> { + // + const identity = await ctx.auth.getUserIdentity(); + if (!identity) throw Unauthorized(); + + const parsedAppUserId = zid('users').safeParse(identity.userId); + return await ctx.runQuery(internal.users._findCurrentByIdentity, { + authUserId: identity.subject, + appUserId: parsedAppUserId.success ? parsedAppUserId.data : undefined, + }); +} diff --git a/apps/meseeks/convex/triggerIsolateState.ts b/apps/meseeks/convex/triggerIsolateState.ts new file mode 100644 index 00000000..bec57016 --- /dev/null +++ b/apps/meseeks/convex/triggerIsolateState.ts @@ -0,0 +1,277 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import { internalMutation, internalQuery } from 'lib/convex'; +import { NotFound } from 'lib/errors'; +import { internal } from './_generated/api'; +import { catFile } from './files.private'; +import { findLoopByKey, isLoopVisibleToOwner } from './loops.private'; +import { enqueueTriggerAction } from './reactor.private'; +import { isTriggerEligible } from './triggers.private'; +import type { Doc, Id } from './_generated/dataModel'; +import type { MutationCtx, QueryCtx } from './_generated/server'; + +const isolateProposalSchema = z.object({ + trigger: zid('triggers'), + skillKey: z.string().min(1), + args: z.record(z.unknown()).default({}), + loop: zid('loops').nullable().optional(), +}); + +const proposalSourceSchema = z.object({ + kind: z.literal('action'), + actionId: zid('actions'), +}); + +export const _context = internalQuery({ + args: { + owner: zid('users'), + actionId: zid('actions'), + }, + handler: async (ctx, { owner, actionId }) => { + // + const action = await ctx.db.get(actionId); + if (!action || action.owner !== owner) throw NotFound(); + if (action.interruptedAt !== undefined || action.status !== 'succeeded') { + return { + action, + triggers: [], + }; + } + const detail = await ctx.db + .query('details') + .withIndex('by_action', (q) => q.eq('action', actionId)) + .first(); + const actionContext = + detail && 'metadata' in detail + ? { + ...action, + result: { + metadata: detail.metadata, + }, + } + : action; + + return { + action: actionContext, + triggers: await triggerContextsForAction(ctx, { + owner, + action, + }), + }; + }, +}); + +export const _acceptProposals = internalMutation({ + args: { + owner: zid('users'), + source: proposalSourceSchema, + proposals: z.array(isolateProposalSchema), + }, + handler: async (ctx, { owner, source, proposals }) => { + // + const target = await deriveTriggerTarget(ctx, { owner, source }); + if (!target) return 0; + + let accepted = 0; + const acceptedTriggers = new Set>(); + for (const proposal of proposals) { + const trigger = await ctx.db.get(proposal.trigger); + if (!trigger || !isTriggerEligible(trigger)) continue; + if (!doesTriggerApplyToTarget({ trigger, target })) continue; + if (!isTriggerVisibleForTarget({ trigger, target, owner })) continue; + + const loop = selectedLoop({ proposal, trigger, target }); + const loopRecord = loop ? await ctx.db.get(loop) : undefined; + if (loopRecord && !isLoopVisibleToOwner({ loop: loopRecord, owner })) continue; + const actionArgs = actionArgsForProposal({ + args: proposal.args, + trigger: proposal.trigger, + }); + + const actionId = await enqueueTriggerAction(ctx, { + owner, + file: target.file, + triggerId: proposal.trigger, + skillKey: proposal.skillKey, + args: actionArgs, + loopKey: loopRecord?.key, + intelligenceKey: target.intelligenceKey ?? loopRecord?.defaultIntelligenceKey, + }); + await ctx.scheduler.runAfter(0, internal.runtime._perform, { + owner, + actionId, + }); + accepted += 1; + acceptedTriggers.add(proposal.trigger); + } + + for (const triggerId of acceptedTriggers) { + const trigger = await ctx.db.get(triggerId); + if (!trigger) continue; + await ctx.db.patch(triggerId, { + uses: trigger.uses + 1, + updatedAt: Date.now(), + }); + } + + return accepted; + }, +}); + +async function triggerContextsForAction( + ctx: QueryCtx, + args: { + owner: Id<'users'>; + action: Doc<'actions'>; + }, +) { + // + const triggers = await eligibleTriggersForAction(ctx, args); + const contexts = []; + + for (const trigger of triggers) { + contexts.push(await triggerContext(ctx, { owner: args.owner, trigger })); + } + + return contexts; +} + +async function eligibleTriggersForAction( + ctx: QueryCtx, + args: { + owner: Id<'users'>; + action: Doc<'actions'>; + }, +) { + // + const fileTriggers = await ctx.db + .query('triggers') + .withIndex('by_file', (q) => q.eq('file', args.action.file)) + .collect(); + const triggers = fileTriggers.filter( + (trigger) => trigger.kind === 'file' && trigger.owner === args.owner && isTriggerEligible(trigger), + ); + + if (!args.action.loopKey) return triggers; + + const loop = await findLoopByKey(ctx, { + owner: args.owner, + key: args.action.loopKey, + }); + if (!loop) return triggers; + + const loopTriggers = await ctx.db + .query('triggers') + .withIndex('by_loop', (q) => q.eq('loop', loop._id)) + .collect(); + const visibleLoopTriggers = []; + + for (const trigger of loopTriggers) { + if (trigger.kind !== 'loop') continue; + if (trigger.owner !== loop.owner) continue; + if (!isTriggerEligible(trigger)) continue; + visibleLoopTriggers.push(trigger); + } + + return triggers.concat(visibleLoopTriggers); +} + +async function triggerContext( + ctx: QueryCtx, + args: { + owner: Id<'users'>; + trigger: Doc<'triggers'>; + }, +) { + // + return { + trigger: args.trigger._id, + kind: args.trigger.kind, + handler: args.trigger.handler, + handlerCode: await catFile(ctx, { + owner: args.trigger.owner, + fileId: args.trigger.handler, + }), + maxUses: args.trigger.maxUses, + uses: args.trigger.uses, + file: args.trigger.kind === 'file' ? args.trigger.file : undefined, + loop: args.trigger.kind === 'loop' ? args.trigger.loop : undefined, + }; +} + +async function deriveTriggerTarget( + ctx: MutationCtx, + args: { + owner: Id<'users'>; + source: z.infer; + }, +) { + // + const action = await ctx.db.get(args.source.actionId); + if (!action || action.owner !== args.owner) throw NotFound(); + if (action.interruptedAt !== undefined || action.status !== 'succeeded') return undefined; + + const loop = action.loopKey + ? await findLoopByKey(ctx, { + owner: args.owner, + key: action.loopKey, + }) + : undefined; + + return { + file: action.file, + loop: loop?._id, + loopOwner: loop?.owner, + intelligenceKey: action.intelligenceKey, + }; +} + +function doesTriggerApplyToTarget(args: { + trigger: Doc<'triggers'>; + target: { + file: Id<'files'>; + loop?: Id<'loops'>; + loopOwner?: Id<'users'>; + }; +}) { + // + if (args.trigger.kind === 'file') return args.trigger.file === args.target.file; + + return args.target.loop !== undefined && args.trigger.loop === args.target.loop; +} + +function isTriggerVisibleForTarget(args: { + trigger: Doc<'triggers'>; + target: { + loopOwner?: Id<'users'>; + }; + owner: Id<'users'>; +}) { + // + if (args.trigger.kind === 'file') return args.trigger.owner === args.owner; + + return args.target.loopOwner !== undefined && args.trigger.owner === args.target.loopOwner; +} + +function selectedLoop(args: { + proposal: z.infer; + trigger: Doc<'triggers'>; + target: { + loop?: Id<'loops'>; + }; +}) { + // + if (args.proposal.loop === null) return undefined; + if (args.proposal.loop) return args.proposal.loop; + if (args.trigger.kind === 'loop') return args.trigger.loop; + + return args.target.loop; +} + +function actionArgsForProposal(args: { args: Record; trigger: Id<'triggers'> }) { + // + return { + ...args.args, + trigger: args.trigger, + }; +} diff --git a/apps/meseeks/convex/triggers.private.ts b/apps/meseeks/convex/triggers.private.ts new file mode 100644 index 00000000..e5b44379 --- /dev/null +++ b/apps/meseeks/convex/triggers.private.ts @@ -0,0 +1,435 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import { defineMutation, defineQuery } from 'lib/convex'; +import { NotFound } from 'lib/errors'; +import { managedLoopTriggers, managedTriggerHandlers } from 'lib/proDefinitions'; +import { authorSchema } from 'schemas/authorSchema'; +import type { Doc, Id } from './_generated/dataModel'; +import type { MutationCtx, QueryCtx } from './_generated/server'; +import { catFile, createFile, ensureFileOwner, findChildByName, writeFileContent } from './files.private'; +import { findLoopByKey, isLoopVisibleToOwner } from './loops.private'; +import { recordMutationAction } from './reactor.private'; + +// Convex stores a finite number; Reactor treats this sentinel as unlimited trigger uses. +export const TRIGGER_MAX_USES_UNLIMITED = Number.MAX_SAFE_INTEGER; + +const triggerMaxUsesSchema = z.number().int().nonnegative().default(TRIGGER_MAX_USES_UNLIMITED); + +export const seedManagedLoopTriggers = defineMutation({ + args: z.object({ + owner: zid('users'), + author: authorSchema, + auditFile: zid('files'), + }), + handler: async (ctx, { owner, author, auditFile }) => { + // + await ensureFileOwner(ctx, { + owner, + fileId: auditFile, + }); + + const handlerFiles = await seedManagedTriggerHandlerFiles(ctx, { + owner, + author, + parent: auditFile, + }); + const triggerIds = []; + + for (const registration of managedLoopTriggers) { + const loop = await findLoopByKey(ctx, { owner, key: registration.loopKey }); + const handler = handlerFiles.get(registration.handlerKey); + if (!loop || !handler) continue; + + triggerIds.push( + await upsertLoopTrigger(ctx, { + owner, + loop: loop._id, + handler, + author, + auditFile, + }), + ); + } + + return triggerIds; + }, +}); + +export const upsertFileTrigger = defineMutation({ + args: z.object({ + owner: zid('users'), + file: zid('files'), + handler: zid('files'), + maxUses: triggerMaxUsesSchema, + author: authorSchema, + auditFile: zid('files').optional(), + }), + handler: async (ctx, { owner, file, handler, maxUses, author, auditFile }) => { + // + await ensureFileOwner(ctx, { owner, fileId: file }); + await ensureFileOwner(ctx, { owner, fileId: handler }); + if (auditFile) await ensureFileOwner(ctx, { owner, fileId: auditFile }); + + const existing = await findFileTriggerByHandler(ctx, { file, handler }); + if (existing) { + if (existing.maxUses === maxUses) return existing._id; + + await ctx.db.patch(existing._id, { + maxUses, + author, + updatedAt: Date.now(), + }); + await recordTriggerMutationAction(ctx, { + owner, + file: auditFile ?? file, + author, + handler, + isUpdate: true, + }); + return existing._id; + } + + const now = Date.now(); + const triggerId = await ctx.db.insert('triggers', { + kind: 'file', + owner, + file, + handler, + maxUses, + uses: 0, + author, + createdAt: now, + updatedAt: now, + }); + await recordTriggerMutationAction(ctx, { + owner, + file: auditFile ?? file, + author, + handler, + isUpdate: false, + }); + + return triggerId; + }, +}); + +export const createFileTrigger = defineMutation({ + args: z.object({ + owner: zid('users'), + file: zid('files'), + handler: zid('files'), + maxUses: triggerMaxUsesSchema, + author: authorSchema, + auditFile: zid('files').optional(), + }), + handler: async (ctx, { owner, file, handler, maxUses, author, auditFile }) => { + // + await ensureFileOwner(ctx, { owner, fileId: file }); + await ensureFileOwner(ctx, { owner, fileId: handler }); + if (auditFile) await ensureFileOwner(ctx, { owner, fileId: auditFile }); + + const now = Date.now(); + const triggerId = await ctx.db.insert('triggers', { + kind: 'file', + owner, + file, + handler, + maxUses, + uses: 0, + author, + createdAt: now, + updatedAt: now, + }); + await recordTriggerMutationAction(ctx, { + owner, + file: auditFile ?? file, + author, + handler, + isUpdate: false, + }); + + return triggerId; + }, +}); + +export const upsertLoopTrigger = defineMutation({ + args: z.object({ + owner: zid('users'), + loop: zid('loops'), + handler: zid('files'), + maxUses: triggerMaxUsesSchema, + author: authorSchema, + auditFile: zid('files'), + }), + handler: async (ctx, { owner, loop, handler, maxUses, author, auditFile }) => { + // + const loopRecord = await ctx.db.get(loop); + if (!loopRecord || loopRecord.owner !== owner) throw NotFound(); + await ensureFileOwner(ctx, { owner, fileId: handler }); + await ensureFileOwner(ctx, { owner, fileId: auditFile }); + + const existing = await findLoopTriggerByHandler(ctx, { loop, handler }); + if (existing) { + if (existing.maxUses === maxUses) return existing._id; + + await ctx.db.patch(existing._id, { + maxUses, + author, + updatedAt: Date.now(), + }); + await recordTriggerMutationAction(ctx, { + owner, + file: auditFile, + author, + handler, + isUpdate: true, + }); + return existing._id; + } + + const now = Date.now(); + const triggerId = await ctx.db.insert('triggers', { + kind: 'loop', + owner, + loop, + handler, + maxUses, + uses: 0, + author, + createdAt: now, + updatedAt: now, + }); + await recordTriggerMutationAction(ctx, { + owner, + file: auditFile, + author, + handler, + isUpdate: false, + }); + + return triggerId; + }, +}); + +export const removeTrigger = defineMutation({ + args: z.object({ + owner: zid('users'), + triggerId: zid('triggers'), + author: authorSchema, + auditFile: zid('files').optional(), + }), + handler: async (ctx, { owner, triggerId, author, auditFile }) => { + // + const trigger = await ctx.db.get(triggerId); + if (!trigger || trigger.owner !== owner) throw NotFound(); + if (auditFile) await ensureFileOwner(ctx, { owner, fileId: auditFile }); + + await ctx.db.delete(triggerId); + await recordTriggerMutationAction(ctx, { + owner, + file: auditFile ?? trigger.handler, + author, + handler: trigger.handler, + isUpdate: false, + isDelete: true, + }); + + return true; + }, +}); + +export const eligibleFileTriggers = defineQuery({ + args: z.object({ + owner: zid('users'), + file: zid('files'), + }), + handler: async (ctx, { owner, file }) => { + // + await ensureFileOwner(ctx, { owner, fileId: file }); + const triggers = await ctx.db + .query('triggers') + .withIndex('by_file', (q) => q.eq('file', file)) + .collect(); + + return triggers.filter( + (trigger) => trigger.kind === 'file' && trigger.owner === owner && isTriggerEligible(trigger), + ); + }, +}); + +export const eligibleLoopTriggers = defineQuery({ + args: z.object({ + owner: zid('users'), + loop: zid('loops'), + }), + handler: async (ctx, { owner, loop }) => { + // + const loopRecord = await ctx.db.get(loop); + if (!loopRecord || !isLoopVisibleToOwner({ loop: loopRecord, owner })) throw NotFound(); + const triggers = await ctx.db + .query('triggers') + .withIndex('by_loop', (q) => q.eq('loop', loop)) + .collect(); + + return triggers.filter( + (trigger) => trigger.kind === 'loop' && trigger.owner === loopRecord.owner && isTriggerEligible(trigger), + ); + }, +}); + +export function isTriggerEligible(trigger: Doc<'triggers'>) { + // + return trigger.uses < trigger.maxUses; +} + +async function seedManagedTriggerHandlerFiles( + ctx: MutationCtx, + args: { + owner: Id<'users'>; + author: z.infer; + parent: Id<'files'>; + }, +) { + // + const directory = await ensureTriggerDirectory(ctx, args); + const files = new Map>(); + + for (const handler of managedTriggerHandlers) { + const existing = await findChildByName(ctx, { + owner: args.owner, + parent: directory, + name: handler.name, + }); + + if (existing) { + if (existing.isPublic !== true) { + await ctx.db.patch(existing._id, { + isPublic: true, + updatedAt: Date.now(), + }); + } + const current = await catFile(ctx, { + owner: args.owner, + fileId: existing._id, + }); + if (current !== handler.body) { + await writeFileContent(ctx, { + owner: args.owner, + fileId: existing._id, + author: args.author, + content: handler.body, + }); + } + files.set(handler.key, existing._id); + continue; + } + + const handlerId = await createFile(ctx, { + owner: args.owner, + parent: directory, + name: handler.name, + author: args.author, + content: handler.body, + isPublic: true, + tags: [ + { key: 'kind', value: 'trigger-handler' }, + { key: 'key', value: handler.key }, + ], + shouldAddInboxTag: false, + }); + files.set(handler.key, handlerId); + } + + return files; +} + +async function ensureTriggerDirectory( + ctx: MutationCtx, + args: { + owner: Id<'users'>; + author: z.infer; + parent: Id<'files'>; + }, +) { + // + const existing = await findChildByName(ctx, { + owner: args.owner, + parent: args.parent, + name: 'triggers', + }); + if (existing) return existing._id; + + return await createFile(ctx, { + owner: args.owner, + parent: args.parent, + name: 'triggers', + author: args.author, + tags: [{ key: 'kind', value: 'directory' }], + shouldAddInboxTag: false, + }); +} + +async function findFileTriggerByHandler( + ctx: QueryCtx | MutationCtx, + args: { + file: Id<'files'>; + handler: Id<'files'>; + }, +) { + // + const triggers = await ctx.db + .query('triggers') + .withIndex('by_file', (q) => q.eq('file', args.file)) + .collect(); + + return triggers.find((trigger) => trigger.kind === 'file' && trigger.handler === args.handler); +} + +async function findLoopTriggerByHandler( + ctx: QueryCtx | MutationCtx, + args: { + loop: Id<'loops'>; + handler: Id<'files'>; + }, +) { + // + const triggers = await ctx.db + .query('triggers') + .withIndex('by_loop', (q) => q.eq('loop', args.loop)) + .collect(); + + return triggers.find((trigger) => trigger.kind === 'loop' && trigger.handler === args.handler); +} + +async function recordTriggerMutationAction( + ctx: MutationCtx, + args: { + owner: Id<'users'>; + file: Id<'files'>; + author: z.infer; + handler: Id<'files'>; + isUpdate: boolean; + isDelete?: boolean; + }, +) { + // + const actionName = args.isDelete ? 'deleteTrigger' : args.isUpdate ? 'updateTrigger' : 'createTrigger'; + await recordMutationAction(ctx, { + owner: args.owner, + file: args.file, + author: args.author, + skillKey: actionName, + args: { + handler: args.handler, + }, + result: { + text: `${actionName} ${args.handler}.`, + files: [ + { + file: args.handler, + path: 'handler', + }, + ], + }, + }); +} diff --git a/apps/meseeks/convex/triggers.ts b/apps/meseeks/convex/triggers.ts new file mode 100644 index 00000000..d685eec7 --- /dev/null +++ b/apps/meseeks/convex/triggers.ts @@ -0,0 +1,118 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import { mutation, query } from 'lib/convex'; +import { NotFound } from 'lib/errors'; +import { ensureFileOwner } from './files.private'; +import { findLoopByKey } from './loops.private'; +import { + TRIGGER_MAX_USES_UNLIMITED, + eligibleFileTriggers, + eligibleLoopTriggers, + removeTrigger, + upsertFileTrigger, + upsertLoopTrigger, +} from './triggers.private'; +import { getCurrentUser } from './users.private'; + +const createFileTriggerSchema = z.object({ + kind: z.literal('file'), + file: zid('files'), + handler: zid('files'), + maxUses: z.number().int().nonnegative().default(TRIGGER_MAX_USES_UNLIMITED), +}); + +const createLoopTriggerSchema = z.object({ + kind: z.literal('loop'), + loop: zid('loops'), + handler: zid('files'), + maxUses: z.number().int().nonnegative().default(TRIGGER_MAX_USES_UNLIMITED), +}); + +const createTriggerSchema = z.union([createFileTriggerSchema, createLoopTriggerSchema]); + +export const create = mutation({ + args: { + registration: createTriggerSchema, + }, + handler: async (ctx, { registration }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + await ensureFileOwner(ctx, { + fileId: registration.handler, + owner: currentUser._id, + }); + + if (registration.kind === 'file') { + return await upsertFileTrigger(ctx, { + owner: currentUser._id, + author: currentUser._id, + file: registration.file, + handler: registration.handler, + maxUses: registration.maxUses, + auditFile: registration.file, + }); + } + + if (!currentUser.rootFileId) throw NotFound(); + + return await upsertLoopTrigger(ctx, { + owner: currentUser._id, + author: currentUser._id, + loop: registration.loop, + handler: registration.handler, + maxUses: registration.maxUses, + auditFile: currentUser.rootFileId, + }); + }, +}); + +export const eligibleForAction = query({ + args: { + actionId: zid('actions'), + }, + handler: async (ctx, { actionId }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + const action = await ctx.db.get(actionId); + if (!action || action.owner !== currentUser._id) throw NotFound(); + await ensureFileOwner(ctx, { + fileId: action.file, + owner: currentUser._id, + }); + + const fileTriggers = await eligibleFileTriggers(ctx, { + owner: currentUser._id, + file: action.file, + }); + if (!action.loopKey) return fileTriggers; + + const loop = await findLoopByKey(ctx, { + owner: currentUser._id, + key: action.loopKey, + }); + if (!loop) return fileTriggers; + + const loopTriggers = await eligibleLoopTriggers(ctx, { + owner: currentUser._id, + loop: loop._id, + }); + + return fileTriggers.concat(loopTriggers); + }, +}); + +export const remove = mutation({ + args: { + triggerId: zid('triggers'), + }, + handler: async (ctx, { triggerId }) => { + // + const currentUser = await getCurrentUser(ctx, {}); + return await removeTrigger(ctx, { + owner: currentUser._id, + triggerId, + author: currentUser._id, + auditFile: currentUser.rootFileId, + }); + }, +}); diff --git a/apps/meseeks/convex/tsconfig.json b/apps/meseeks/convex/tsconfig.json index 16e93f53..19ecd637 100644 --- a/apps/meseeks/convex/tsconfig.json +++ b/apps/meseeks/convex/tsconfig.json @@ -7,8 +7,7 @@ "paths": { "convex/*": ["./*"], "lib/*": ["../lib/*"], - "schemas/*": ["../schemas/*"], - "skills/*": ["../skills/*"] + "schemas/*": ["../schemas/*"] }, /* These settings are not required by Convex and can be modified. */ "allowJs": false, diff --git a/apps/meseeks/convex/users.private.ts b/apps/meseeks/convex/users.private.ts index 5b1a54d7..965cb60d 100644 --- a/apps/meseeks/convex/users.private.ts +++ b/apps/meseeks/convex/users.private.ts @@ -3,89 +3,82 @@ import { z } from 'zod/v3'; import { defineMutation, defineQuery } from 'lib/convex'; import { NotFound, Unauthorized } from 'lib/errors'; import { asBigInt } from 'lib/money'; +import { managedSeedVersion, rootFileNameFor } from 'lib/proDefinitions'; import { tokenSchema } from 'schemas/topUpSchema'; +import type { Doc } from './_generated/dataModel'; +import { findChildByName, createFile, adjustFileBudget } from './files.private'; +import { seedManagedLoops } from './loops.private'; +import { seedManagedRoutes } from './routes.private'; +import { seedManagedSkills } from './skills.private'; import { findActiveSubscriptions } from './subscriptions.private'; -import { addTaskWithActions } from './tasks.private'; import { addFreeCredits } from './transactions.private'; -import { setUserPreference } from './users/preferences.private'; +import { seedManagedLoopTriggers } from './triggers.private'; import { components } from './_generated/api'; -const addInitialTask = defineMutation({ +export const bootstrapUserWorkspace = defineMutation({ args: z.object({ userId: zid('users'), }), handler: async (ctx, { userId }) => { // - const initialTaskId = await addTaskWithActions(ctx, { - author: userId, + const user = await findUser(ctx, { userId }); + if (!user) throw NotFound(); + + await addFreeCredits(ctx, { 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: {}, - }, - ], + value: { + symbol: 'USD', + amount: asBigInt({ dollars: 2 }), + }, + description: 'Welcome credits', }); - await ctx.db.patch(userId, { initialTaskId }); - - return initialTaskId; - }, -}); + const rootName = rootFileNameFor({ name: user.name }); + const existingRoot = await findChildByName(ctx, { + owner: userId, + name: rootName, + }); + const rootFileId = + user.rootFileId ?? + existingRoot?._id ?? + (await createFile(ctx, { + owner: userId, + name: rootName, + author: userId, + content: `# ${rootName}\n\nThis is your PRO root file. It can have content, children, tags, actions, triggers, routes, and budget.`, + tags: [ + { key: 'kind', value: 'task' }, + { key: 'status', value: 'active' }, + ], + shouldAddInboxTag: false, + })); + + await ctx.db.patch(userId, { rootFileId }); + + const index = await findChildByName(ctx, { + owner: userId, + parent: rootFileId, + name: 'index.md', + }); + if (!index) { + await createFile(ctx, { + owner: userId, + parent: rootFileId, + name: 'index.md', + author: userId, + content: `# ${rootName}\n\nPRO reads this file as the preferred main content for your root.`, + shouldAddInboxTag: false, + }); + } -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, + await adjustFileBudget(ctx, { + owner: userId, + file: rootFileId, + author: userId, + amount: asBigInt({ dollars: 1 }), }); + + return rootFileId; }, }); @@ -96,27 +89,41 @@ export const seedUserIfNeeded = defineMutation({ 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', - }); + if (!user) throw NotFound(); - // TODO: users should be able to spawn their own Convex instance for full isolation and control + if (!user.isReady) { + console.info('new user', userId); - // await addInitialTask(ctx, { userId }); - await setDefaultPreferences(ctx, { userId }); + await bootstrapUserWorkspace(ctx, { userId }); + await ctx.db.patch(userId, { isReady: true }); + } + }, +}); - await ctx.db.patch(userId, { isReady: true }); +export const syncProDefinitions = defineMutation({ + args: z.object({ + owner: zid('users'), + }), + handler: async (ctx, { owner }) => { + // + const user = await findUser(ctx, { userId: owner }); + if (!user) throw NotFound(); + if (!user.rootFileId) throw NotFound(); + + const wasCurrent = user.managedSeedVersion === managedSeedVersion; + const loops = await seedManagedLoops(ctx, { owner, author: owner, auditFile: user.rootFileId }); + const triggers = await seedManagedLoopTriggers(ctx, { owner, author: owner, auditFile: user.rootFileId }); + const routes = await seedManagedRoutes(ctx, { owner, author: owner }); + const skills = await seedManagedSkills(ctx, { owner, author: owner, parent: user.rootFileId }); + await ctx.db.patch(owner, { managedSeedVersion }); + + return { + status: wasCurrent ? 'checked' : 'synced', + loops: loops.length, + triggers: triggers.length, + routes: routes.length, + skills: skills.length, + }; }, }); @@ -159,9 +166,7 @@ export const findUser = defineQuery({ args: z.object({ userId: zid('users'), }), - handler: async (ctx, { userId }) => { - return await ctx.db.get(userId); - }, + handler: async (ctx, { userId }) => await ctx.db.get(userId), }); export const findUserByAuthUserId = defineQuery({ @@ -244,27 +249,21 @@ export const getCurrentUser = defineQuery({ args: z.object({}), handler: async (ctx) => { // - // get data from JWT const identity = await ctx.auth.getUserIdentity(); if (!identity) throw Unauthorized(); const authUserId = identity.subject; const { success, data: appUserId } = zid('users').safeParse(identity.userId); - // grab the user from app users table if (success) { const user = await findUser(ctx, { userId: appUserId }); - if (user) return user; + if (user) return withSpendableBalance(user); } - // fallback to Better Auth user table if needed; - // the first convex jwt after sign-in can be minted before better auth's - // `user.userId` bridge shows up in the token payload, so fall back to the - // auth user id during that window. const user = await findUserByAuthUserId(ctx, { authUserId }); if (!user) throw Unauthorized(); - return user; + return withSpendableBalance(user); }, }); @@ -309,6 +308,7 @@ const createUser = defineMutation({ image, isReady: false, balanceUSD: 0n, + committedBudgetUSD: 0n, isFounder: false, }); @@ -319,6 +319,14 @@ const createUser = defineMutation({ }, }); +function withSpendableBalance(user: Doc<'users'>) { + // + return { + ...user, + spendableBalanceUSD: (user.balanceUSD ?? 0n) - (user.committedBudgetUSD ?? 0n), + }; +} + const linkUser = defineMutation({ args: updateExistingUserArgs, handler: async (ctx, args) => { @@ -336,6 +344,8 @@ const linkUser = defineMutation({ image, }); + await seedUserIfNeeded(ctx, { userId }); + return userId; }, }); @@ -357,6 +367,8 @@ const patchUser = defineMutation({ image, }); + await seedUserIfNeeded(ctx, { userId }); + return userId; }, }); diff --git a/apps/meseeks/convex/users.ts b/apps/meseeks/convex/users.ts index d39a9a11..c097d864 100644 --- a/apps/meseeks/convex/users.ts +++ b/apps/meseeks/convex/users.ts @@ -1,6 +1,8 @@ -import { internalMutation, query } from 'lib/convex'; -import { findActiveTasks } from './tasks.private'; -import { addUser, getCurrentUser, isProSubscriber, updateUser } from './users.private'; +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import { internalMutation, internalQuery, query } from 'lib/convex'; +import { Unauthorized } from 'lib/errors'; +import { addUser, findUser, findUserByAuthUserId, getCurrentUser, isProSubscriber, 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. @@ -15,6 +17,25 @@ export const _updateUser = internalMutation({ handler: updateUser, }); +export const _findCurrentByIdentity = internalQuery({ + args: { + authUserId: z.string().min(1), + appUserId: zid('users').optional(), + }, + handler: async (ctx, { authUserId, appUserId }) => { + // + if (appUserId) { + const user = await findUser(ctx, { userId: appUserId }); + if (user) return user; + } + + const user = await findUserByAuthUserId(ctx, { authUserId }); + if (!user) throw Unauthorized(); + + return user; + }, +}); + // public entrypoint used by the app; keeps auth + allowlist logic centralized in users.private.getCurrentUser export const current = query({ args: getCurrentUser.args.shape, @@ -42,8 +63,7 @@ export const findLockedBalance = query({ 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 currentUser.committedBudgetUSD ?? 0n; }, }); diff --git a/apps/meseeks/convex/users/preferences.ts b/apps/meseeks/convex/users/preferences.ts index 92c9505e..b027f3c9 100644 --- a/apps/meseeks/convex/users/preferences.ts +++ b/apps/meseeks/convex/users/preferences.ts @@ -9,7 +9,7 @@ export const _getUserPreference = internalQuery({ handler: findUserPreference, }); -// used by skills/builtIn/setUserInfo.ts so the ai can persist learned user profile data +// internal write path for trusted preference updates export const _setUserPreference = internalMutation({ args: setUserPreference.args.shape, handler: setUserPreference, diff --git a/apps/meseeks/convex/users/themes.ts b/apps/meseeks/convex/users/themes.ts index cc10d155..08f5d9aa 100644 --- a/apps/meseeks/convex/users/themes.ts +++ b/apps/meseeks/convex/users/themes.ts @@ -1,8 +1,9 @@ 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 { findUser, findUserByAuthUserId, getCurrentUser } from '../users.private'; import { clearUserPreference, findUserPreference, setUserPreference } from './preferences.private'; +import { zid } from 'convex-helpers/server/zod3'; const themePreferenceKey = 'themeId'; @@ -26,7 +27,14 @@ export const get = query({ args: {}, handler: async (ctx) => { // - const user = await getCurrentUser(ctx, {}); + const user = await findThemeUser(ctx); + if (!user) { + return { + themeId: null, + themeIconNameById, + }; + } + const themePreference = await findUserPreference(ctx, { userId: user._id, key: themePreferenceKey, @@ -39,6 +47,20 @@ export const get = query({ }, }); +async function findThemeUser(ctx: Parameters[0]) { + // + const identity = await ctx.auth.getUserIdentity(); + if (!identity) return null; + + const parsedAppUserId = zid('users').safeParse(identity.userId); + if (parsedAppUserId.success) { + const user = await findUser(ctx, { userId: parsedAppUserId.data }); + if (user) return user; + } + + return await findUserByAuthUserId(ctx, { authUserId: identity.subject }); +} + export const set = mutation({ args: { themeId: appThemeIdSchema, diff --git a/apps/meseeks/lib/bigintJson.ts b/apps/meseeks/lib/bigintJson.ts index eb475cdb..5b39744f 100644 --- a/apps/meseeks/lib/bigintJson.ts +++ b/apps/meseeks/lib/bigintJson.ts @@ -17,19 +17,17 @@ export function bigIntFromJSON(value: unknown): unknown { return value.map(bigIntFromJSON); } - if (typeof value === 'object') { + if (isRecord(value)) { // - const obj = value as Record; - // check for BigInt marker - if ('__bigint__' in obj && typeof obj['__bigint__'] === 'string') { - return BigInt(obj['__bigint__']); + if (typeof value['__bigint__'] === 'string') { + return BigInt(value['__bigint__']); } // recursively process object properties const result: Record = {}; - for (const key in obj) { - result[key] = bigIntFromJSON(obj[key]); + for (const key in value) { + result[key] = bigIntFromJSON(value[key]); } return result; @@ -37,3 +35,7 @@ export function bigIntFromJSON(value: unknown): unknown { return value; } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/apps/meseeks/lib/daytonaSandbox.ts b/apps/meseeks/lib/daytonaSandbox.ts new file mode 100644 index 00000000..202ffe5b --- /dev/null +++ b/apps/meseeks/lib/daytonaSandbox.ts @@ -0,0 +1,210 @@ +import { Buffer } from 'node:buffer'; +import { Daytona } from '@daytona/sdk'; +import type { + CreateSandboxBaseParams, + CreateSandboxFromImageParams, + CreateSandboxFromSnapshotParams, + DaytonaConfig, + Sandbox, +} from '@daytona/sdk'; +import { z } from 'zod/v3'; +import type { SandboxAdapter, SandboxRunInput } from './reactor/adapters'; +import { createDaytonaSandboxAdapter } from './reactor/runtimeAdapters'; + +export const daytonaSettingsSchema = z.object({ + apiKey: z.string().min(1), + apiUrl: z.string().url().optional(), + target: z.string().min(1).optional(), + image: z.string().min(1).optional(), +}); + +export const daytonaOutputSchema = z.object({ + path: z.string().min(1), + contentType: z.string().min(1).optional(), +}); + +const executeResponseSchema = z + .object({ + exitCode: z.number().int(), + result: z.string().default(''), + artifacts: z + .object({ + stdout: z.string().optional(), + }) + .passthrough() + .optional(), + }) + .passthrough(); + +type DaytonaSettings = z.infer; +type DaytonaOutput = z.infer; + +const VIRTUAL_WORKSPACE_ROOT = '/workspace'; +const DAYTONA_WORKSPACE_ROOT = '/tmp/reactor-workspace'; + +export function materializeDaytonaWorkspaceText(text: string) { + // + return text.split(VIRTUAL_WORKSPACE_ROOT).join(DAYTONA_WORKSPACE_ROOT); +} + +export function createDaytonaReactorSandbox(args: { + settings: DaytonaSettings; + declaredOutputs: DaytonaOutput[]; +}): SandboxAdapter { + // + return createDaytonaSandboxAdapter({ + declaredOutputPaths: () => args.declaredOutputs.map((output) => output.path), + create: async (input) => { + const daytona = new Daytona(daytonaConfig(args.settings)); + const sandbox = await createSandbox({ + daytona, + settings: args.settings, + input, + }); + + return { + id: sandbox.id, + writeFile: async (path, content) => { + const daytonaPath = toDaytonaPath(path); + await ensureParentFolders(sandbox, daytonaPath); + await sandbox.fs.uploadFile(Buffer.from(content), daytonaPath, seconds(input.timeoutMs)); + }, + execute: async (command, options) => { + const raw = await sandbox.process.executeCommand( + toDaytonaCommand(command), + DAYTONA_WORKSPACE_ROOT, + toDaytonaEnv(options.env), + seconds(options.timeoutMs), + ); + const parsed = executeResponseSchema.parse(raw); + + return { + stdout: parsed.artifacts?.stdout ?? parsed.result, + stderr: '', + exitCode: parsed.exitCode, + metadata: { + provider: 'daytona', + sdk: '@daytona/sdk', + sandboxId: sandbox.id, + image: args.settings.image, + target: args.settings.target, + workspaceRoot: DAYTONA_WORKSPACE_ROOT, + }, + }; + }, + readFile: async (path) => { + const bytes = await sandbox.fs.downloadFile(toDaytonaPath(path), seconds(input.timeoutMs)); + return new Uint8Array(bytes); + }, + close: async () => { + await cleanupSandbox({ daytona, sandbox }); + }, + }; + }, + }); +} + +function toDaytonaPath(path: string) { + // + if (path === VIRTUAL_WORKSPACE_ROOT) return DAYTONA_WORKSPACE_ROOT; + if (path.startsWith(`${VIRTUAL_WORKSPACE_ROOT}/`)) { + return `${DAYTONA_WORKSPACE_ROOT}${path.slice(VIRTUAL_WORKSPACE_ROOT.length)}`; + } + + return path; +} + +function toDaytonaCommand(command: string) { + // + return command.split(VIRTUAL_WORKSPACE_ROOT).join(DAYTONA_WORKSPACE_ROOT); +} + +function toDaytonaEnv(env: Record) { + // + const mapped: Record = {}; + for (const [key, value] of Object.entries(env)) { + mapped[key] = value.split(VIRTUAL_WORKSPACE_ROOT).join(DAYTONA_WORKSPACE_ROOT); + } + + return mapped; +} + +function daytonaConfig(settings: DaytonaSettings): DaytonaConfig { + // + const config: DaytonaConfig = { + apiKey: settings.apiKey, + otelEnabled: false, + }; + if (settings.apiUrl) config.apiUrl = settings.apiUrl; + if (settings.target) config.target = settings.target; + + return config; +} + +async function createSandbox(args: { daytona: Daytona; settings: DaytonaSettings; input: SandboxRunInput }) { + // + const base: CreateSandboxBaseParams = { + language: 'typescript', + envVars: args.input.env, + labels: { + reactor: 'true', + action: args.input.actionId, + }, + ephemeral: true, + autoDeleteInterval: 0, + autoStopInterval: 15, + }; + const timeout = seconds(args.input.timeoutMs); + + if (args.settings.image) { + const params: CreateSandboxFromImageParams = { + ...base, + image: args.settings.image, + }; + return await args.daytona.create(params, { timeout }); + } + + const params: CreateSandboxFromSnapshotParams = base; + return await args.daytona.create(params, { timeout }); +} + +async function ensureParentFolders(sandbox: Sandbox, path: string) { + // + const folders = parentFolders(path); + + for (const folder of folders) { + await sandbox.fs.createFolder(folder, '755').catch(() => {}); + } +} + +function parentFolders(path: string) { + // + const folders = []; + const parts = path.split('/'); + let current = ''; + + for (const part of parts) { + if (!part) continue; + current = `${current}/${part}`; + if (current === path) break; + folders.push(current); + } + + return folders; +} + +async function cleanupSandbox(args: { daytona: Daytona; sandbox: Sandbox }) { + // + await args.daytona.delete(args.sandbox, 60).catch((error: unknown) => { + console.warn('Daytona sandbox cleanup failed', { + sandboxId: args.sandbox.id, + message: error instanceof Error ? error.message : 'Unknown cleanup failure.', + }); + }); + await args.daytona[Symbol.asyncDispose](); +} + +function seconds(ms: number) { + // + return Math.max(1, Math.ceil(ms / 1000)); +} diff --git a/apps/meseeks/lib/errors.ts b/apps/meseeks/lib/errors.ts index e8634282..810a9460 100644 --- a/apps/meseeks/lib/errors.ts +++ b/apps/meseeks/lib/errors.ts @@ -4,7 +4,7 @@ import { Doc } from 'convex/_generated/dataModel'; export const NOT_FOUND_ERROR = 'Not Found'; export const UNAUTHORIZED_ERROR = 'Unauthorized'; export const INSUFFICIENT_ACCOUNT_FUNDS_ERROR = 'Insufficient Account Balance'; -export const NOT_ENOUGH_BUDGET_ERROR = 'Not Enough Task Budget'; +export const NOT_ENOUGH_BUDGET_ERROR = 'Not Enough File Budget'; const createError = (code: string) => (message?: string) => new ConvexError({ diff --git a/apps/meseeks/lib/proDefinitions.ts b/apps/meseeks/lib/proDefinitions.ts new file mode 100644 index 00000000..f0f9bc3a --- /dev/null +++ b/apps/meseeks/lib/proDefinitions.ts @@ -0,0 +1,287 @@ +import { z } from 'zod/v3'; +import { + INTELLIGENCES, + RECOMMENDED_INTELLIGENCE_KEYS, + displayIntelligence, + estimateIntelligenceCost, + referenceIntelligence, + referenceIntelligenceSelection, + type Intelligence, + type IntelligenceKey, +} from 'schemas/intelligenceSchema'; +import { skillInputArgumentSchema } from 'schemas/skillSchema'; + +export const managedSeedVersion = '2026-06-09.pro-reference-vfs-runtime.8'; + +export { estimateIntelligenceCost, referenceIntelligence, referenceIntelligenceSelection }; +export type { Intelligence, IntelligenceKey }; + +export const intelligences = Object.values(INTELLIGENCES).map((intelligence) => + displayIntelligence({ key: intelligence.key }), +); + +export const recommendedIntelligences = RECOMMENDED_INTELLIGENCE_KEYS.map((key) => displayIntelligence({ key })); + +const loopDefinitionSchema = z.object({ + key: z.string().min(1), + name: z.string().min(1), + description: z.string().default(''), + defaultIntelligenceKey: z.string().min(1), + visual: z.object({ + icon: z.string().min(1), + color: z.string().min(1), + tint: z.string().min(1), + }), +}); + +export type ManagedLoop = z.infer; + +export const managedLoops: ManagedLoop[] = [ + loopDefinitionSchema.parse({ + key: '@pro/Ask', + name: 'Ask', + description: 'Reply once, then stop.', + defaultIntelligenceKey: 'Cheap', + visual: { + icon: 'message-circle', + color: 'sky', + tint: 'sky', + }, + }), + loopDefinitionSchema.parse({ + key: '@pro/Seek', + name: 'Seek', + description: 'Plan, iterate, and stop when done or blocked.', + defaultIntelligenceKey: 'Efficient', + visual: { + icon: 'radar', + color: 'emerald', + tint: 'emerald', + }, + }), +]; + +const planInstructions = [ + 'You are running the Seek planning step for a PRO task file.', + 'Infer the durable task state from the file content, tags, latest user message, and recent actions.', + 'The Latest user message is the primary new human instruction. The existing file content is prior state to revise, not a reason to ignore that message.', + 'Do not answer the user directly. Do not write chat prose.', + 'Return JSON only, with no markdown fence and no text before or after it.', + 'The JSON shape is: {"title":"short title under 60 chars","body":"MDX task plan/instructions that should replace the file body","tags":[{"key":"kind","value":"task"},{"key":"status","value":"active"}],"note":"short user-visible summary of what changed"}.', + 'Use body for future work and durable constraints. If the user corrected a wrong assumption, include the correction and wrong assumption in the body so later work does not repeat it.', + 'Use tags to update task-file routing/status conventions when needed. Include kind=task and status=active for active task files.', + 'Internal action IDs, trigger IDs, loop IDs, and scheduler metadata are debug-only. Do not copy them into the task body unless the human explicitly wrote them.', + 'Do not claim the task is complete during planning.', +].join('\n'); + +const iterateInstructions = [ + 'You are running the Seek iteration step for a PRO task file.', + 'Use the plan, current file content, and recent actions to make concrete progress.', + 'Return JSON only, with no markdown fence and no text before or after it.', + 'The JSON shape is: {"body":"optional replacement file body when you change durable task state","tags":[{"key":"status","value":"done"}],"state":"continue|done|blocked","note":"short user-visible summary"}.', + 'If the plan says to update the file body and you can do it now, return body with the replacement content.', + 'If body still contains an unchecked checklist item or an explicit Next step, state must not be done.', + 'If the task is complete after your mutation, include status=done and state=done.', + 'If you need the user or cannot continue safely, use state=blocked.', + 'Otherwise use state=continue.', + 'Internal action IDs, trigger IDs, loop IDs, and scheduler metadata are debug-only. Do not mention them unless the human explicitly wrote them.', +].join('\n'); + +const managedSkillSchema = z.object({ + key: z.string().min(1), + name: z.string().min(1), + description: z.string().default(''), + kind: z.literal('think'), + input: z.array(skillInputArgumentSchema).default([]), + body: z.string().default(''), +}); + +export type ManagedSkill = z.infer; + +export const managedSkills: ManagedSkill[] = [ + managedSkillSchema.parse({ + key: 'plan', + name: 'plan', + description: 'Update the task file into a durable plan.', + kind: 'think', + input: [], + body: planInstructions, + }), + managedSkillSchema.parse({ + key: 'iterate', + name: 'iterate', + description: 'Advance a planned task until done or blocked.', + kind: 'think', + input: [ + { + key: 'maxDepth', + type: 'integer', + required: false, + description: 'Maximum Seek iterations allowed for this run.', + }, + { + key: 'iteration', + type: 'integer', + required: false, + description: 'Current Seek iteration number.', + }, + ], + body: iterateInstructions, + }), +]; + +const managedComponentSchema = z.object({ + key: z.string().min(1), + name: z.string().min(1), + body: z.string().min(1), +}); + +export type ManagedComponent = z.infer; + +function routeComponentBody(value: unknown) { + // + return JSON.stringify(value, null, 2); +} + +export const managedComponents: ManagedComponent[] = [ + managedComponentSchema.parse({ + key: '@pro/components/QuickCreate', + name: 'QuickCreate.reactor.json', + body: routeComponentBody({ + primitive: 'quick-create', + }), + }), + managedComponentSchema.parse({ + key: '@pro/components/InboxList', + name: 'InboxList.reactor.json', + body: routeComponentBody({ + primitive: 'file-list', + filter: 'inbox', + }), + }), + managedComponentSchema.parse({ + key: '@pro/components/TaskList', + name: 'TaskList.reactor.json', + body: routeComponentBody({ + primitive: 'file-list', + filter: 'tasks', + }), + }), + managedComponentSchema.parse({ + key: '@pro/components/FileWorkspace', + name: 'FileWorkspace.reactor.json', + body: routeComponentBody({ + primitive: 'file-workspace', + list: 'tasks', + }), + }), +]; + +const managedTriggerHandlerSchema = z.object({ + key: z.string().min(1), + name: z.string().min(1), + body: z.string().min(1), +}); + +export type ManagedTriggerHandler = z.infer; + +export const managedTriggerHandlers: ManagedTriggerHandler[] = [ + managedTriggerHandlerSchema.parse({ + key: '@pro/triggers/Ask', + name: 'Ask.trigger.js', + body: `(context) => { + if (context.action?.skillKey !== "say") return { proposals: [] }; + + return { + proposals: [{ + skillKey: "think", + args: { mode: "reply" }, + }], + }; +}`, + }), + managedTriggerHandlerSchema.parse({ + key: '@pro/triggers/Seek', + name: 'Seek.trigger.js', + body: `(context) => { + const action = context.action || {}; + const metadata = action.result?.metadata || {}; + const maxDepth = 8; + + if (action.skillKey === "say") { + return { + proposals: [{ + skillKey: "plan", + args: {}, + }], + }; + } + + if (action.skillKey === "plan") { + return { + proposals: [{ + skillKey: "iterate", + args: { maxDepth, iteration: 1 }, + }], + }; + } + + const iteration = Number(action.args?.iteration || 1); + if (action.skillKey === "iterate" && metadata.seekState === "continue" && iteration < maxDepth) { + return { + proposals: [{ + skillKey: "iterate", + args: { maxDepth, iteration: iteration + 1 }, + }], + }; + } + + return { proposals: [] }; +}`, + }), +]; + +const managedLoopTriggerSchema = z.object({ + loopKey: z.string().min(1), + handlerKey: z.string().min(1), +}); + +export type ManagedLoopTrigger = z.infer; + +export const managedLoopTriggers: ManagedLoopTrigger[] = [ + managedLoopTriggerSchema.parse({ + loopKey: '@pro/Ask', + handlerKey: '@pro/triggers/Ask', + }), + managedLoopTriggerSchema.parse({ + loopKey: '@pro/Seek', + handlerKey: '@pro/triggers/Seek', + }), +]; + +export const managedRoutes = [ + { + slug: '/', + componentKey: '@pro/components/QuickCreate', + }, + { + slug: '/inbox', + componentKey: '@pro/components/InboxList', + }, + { + slug: '/tasks', + componentKey: '@pro/components/TaskList', + }, + { + slug: '/tasks/:id', + componentKey: '@pro/components/FileWorkspace', + }, + { + slug: '/new', + componentKey: '@pro/components/QuickCreate', + }, +]; + +export function rootFileNameFor(user: { name?: string }) { + return `${user.name || 'My'} Life`; +} diff --git a/apps/meseeks/lib/reactor/adapters.ts b/apps/meseeks/lib/reactor/adapters.ts new file mode 100644 index 00000000..6d976dd6 --- /dev/null +++ b/apps/meseeks/lib/reactor/adapters.ts @@ -0,0 +1,103 @@ +import { z } from 'zod/v3'; +import { contentPointerSchema } from 'schemas/fileSchema'; + +export const storageWriteSchema = z.object({ + key: z.string().min(1), + contentType: z.string().min(1).optional(), + size: z.number().int().nonnegative(), +}); + +const triggerProposalSchema = z.object({ + skillKey: z.string().min(1), + args: z.record(z.unknown()).default({}), + loop: z.string().min(1).nullable().optional(), +}); + +export type ContentPointer = z.infer; +export type StorageWrite = z.infer; + +export type ObjectReadRange = + | { + offset: number; + length: number; + } + | { + suffixLength: number; + }; + +export interface ObjectStorageAdapter { + read(pointer: ContentPointer): Promise; + readRange?(pointer: ContentPointer, range: ObjectReadRange): Promise; + write(input: { bytes: Uint8Array; contentType?: string }): Promise; + delete(key: string): Promise; +} + +export interface SandboxAdapter { + run(input: SandboxRunInput): Promise; + cancel(runId: string): Promise; +} + +export type SandboxRunInput = { + actionId: string; + files: Array<{ + path: string; + content: Uint8Array; + }>; + command: string; + env: Record; + timeoutMs: number; +}; + +export type SandboxRunResult = { + runId: string; + stdout: string; + stderr: string; + exitCode: number; + declaredOutputs: Array<{ + path: string; + bytes: Uint8Array; + contentType?: string; + }>; + metadata: Record; +}; + +export interface TriggerIsolateAdapter { + evaluate(input: TriggerIsolateInput): Promise; +} + +export type TriggerIsolateInput = { + code: string; + context: Record; + timeoutMs: number; +}; + +export type TriggerIsolateResult = { + proposals: Array>; + metadata: Record; +}; + +export interface IntelligenceAdapter { + run(input: IntelligenceRunInput): Promise; +} + +export type IntelligenceRunInput = { + intelligence: string; + instructions: string; + input: Array>; + settings: Record; + maxOutputTokens: number; +}; + +export type IntelligenceRunResult = { + text: string; + costs: Array<{ + symbol: 'USD'; + amount: bigint; + description: string; + }>; + providerItems: Array>; + reasoningSummaries: string[]; + metadata: Record; +}; + +export const triggerProposalListSchema = z.array(triggerProposalSchema); diff --git a/apps/meseeks/lib/reactor/instincts.ts b/apps/meseeks/lib/reactor/instincts.ts new file mode 100644 index 00000000..2183eaf0 --- /dev/null +++ b/apps/meseeks/lib/reactor/instincts.ts @@ -0,0 +1,335 @@ +import { z } from 'zod/v3'; +import { skillInputArgumentSchema } from 'schemas/skillSchema'; + +const instinctSkillSchema = z.object({ + key: z.string().min(1), + name: z.string().min(1), + description: z.string().min(1), + input: z.array(skillInputArgumentSchema).default([]), + body: z.string().default(''), +}); + +export type InstinctSkill = z.infer; + +export const instinctSkills = [ + instinctSkillSchema.parse({ + key: 'say', + name: 'Say', + description: 'Records a human message on a file.', + input: [ + { + key: 'message', + type: 'string', + required: true, + description: 'Human message text to append to the file action ledger.', + }, + ], + }), + instinctSkillSchema.parse({ + key: 'think', + name: 'Think', + description: 'Runs intelligence over the current file context and returns a reply.', + input: [ + { + key: 'mode', + type: 'string', + required: false, + description: 'Optional thinking mode, such as reply.', + }, + ], + body: [ + 'You are PRO acting inside a user-owned file.', + 'Answer the user directly from the visible file context and recent action history.', + 'When the user asks for changes, prefer concrete file mutations through follow-up skills instead of pretending work happened.', + ].join('\n'), + }), + instinctSkillSchema.parse({ + key: 'execute', + name: 'Execute', + description: 'Runs an approved command in an isolated sandbox.', + input: [ + { + key: 'command', + type: 'string', + required: true, + description: 'Command to run through the sandbox.', + }, + { + key: 'timeoutMs', + type: 'integer', + required: false, + description: 'Optional execution timeout in milliseconds.', + }, + { + key: 'env', + type: 'json', + required: false, + description: 'Non-secret environment variables.', + }, + ], + }), + instinctSkillSchema.parse({ + key: 'request', + name: 'Request', + description: 'Performs an HTTP request through the trusted request path.', + input: [ + { + key: 'url', + type: 'string', + required: true, + description: 'Request URL.', + }, + { + key: 'method', + type: 'string', + required: false, + description: 'Request method.', + }, + { + key: 'headers', + type: 'json', + required: false, + description: 'Typed literal or environment-reference headers.', + }, + ], + }), + instinctSkillSchema.parse({ + key: 'createFile', + name: 'Create File', + description: 'Creates a file in the VFS.', + input: [ + { + key: 'parent', + type: 'file', + required: false, + description: 'Parent file id. Omit for a root-level file.', + }, + { + key: 'name', + type: 'string', + required: true, + description: 'New file name, unique within the parent.', + }, + { + key: 'content', + type: 'string', + required: false, + description: 'Optional initial text content.', + }, + { + key: 'tags', + type: 'json', + required: false, + description: 'Optional string key/value tags.', + }, + ], + }), + instinctSkillSchema.parse({ + key: 'updateFileMetadata', + name: 'Update File Metadata', + description: 'Updates file metadata and tags.', + input: [ + { + key: 'name', + type: 'string', + required: false, + description: 'Optional replacement file name.', + }, + { + key: 'tags', + type: 'json', + required: false, + description: 'Optional tag updates.', + }, + ], + }), + instinctSkillSchema.parse({ + key: 'tag', + name: 'Tag', + description: 'Adds or updates VFS tags.', + input: [ + { + key: 'key', + type: 'string', + required: true, + description: 'Tag key.', + }, + { + key: 'value', + type: 'string', + required: true, + description: 'Tag value.', + }, + ], + }), + instinctSkillSchema.parse({ + key: 'untag', + name: 'Untag', + description: 'Removes a VFS tag.', + input: [ + { + key: 'key', + type: 'string', + required: true, + description: 'Tag key.', + }, + ], + }), + instinctSkillSchema.parse({ + key: 'move', + name: 'Move', + description: 'Moves or renames a file without changing its id.', + input: [ + { + key: 'parent', + type: 'file', + required: false, + description: 'Destination parent file id.', + }, + { + key: 'name', + type: 'string', + required: false, + description: 'Replacement name.', + }, + ], + }), + instinctSkillSchema.parse({ + key: 'copyFile', + name: 'Copy File', + description: 'Copies any visible file into the current user space.', + input: [ + { + key: 'source', + type: 'file', + required: true, + description: 'Visible source file id.', + }, + { + key: 'parent', + type: 'file', + required: false, + description: 'Destination parent file id.', + }, + { + key: 'name', + type: 'string', + required: true, + description: 'New copied file name.', + }, + ], + }), + instinctSkillSchema.parse({ + key: 'deleteFile', + name: 'Delete File', + description: 'Deletes a user-owned file.', + input: [ + { + key: 'file', + type: 'file', + required: true, + description: 'File id to delete.', + }, + ], + }), + instinctSkillSchema.parse({ + key: 'updateBudget', + name: 'Update Budget', + description: 'Changes a file budget by a signed amount.', + input: [ + { + key: 'amount', + type: 'bigint', + required: true, + description: 'Signed USD energy amount in money precision units.', + }, + ], + }), + instinctSkillSchema.parse({ + key: 'render', + name: 'Render', + description: 'Renders a file for a human or intelligence context.', + input: [ + { + key: 'file', + type: 'file', + required: false, + description: 'File id. Defaults to the current file.', + }, + ], + }), + instinctSkillSchema.parse({ + key: 'interrupt', + name: 'Interrupt', + description: 'Interrupts older reaction work on the current file.', + input: [], + }), + instinctSkillSchema.parse({ + key: 'createLoop', + name: 'Create Loop', + description: 'Creates a loop registration.', + input: [ + { + key: 'key', + type: 'string', + required: true, + description: 'Loop key.', + }, + { + key: 'name', + type: 'string', + required: true, + description: 'Loop display name.', + }, + ], + }), + instinctSkillSchema.parse({ + key: 'createTrigger', + name: 'Create Trigger', + description: 'Creates a lightweight trigger handler registration.', + input: [ + { + key: 'kind', + type: 'string', + required: true, + description: 'Trigger kind: file or loop.', + }, + { + key: 'handler', + type: 'file', + required: true, + description: 'VFS handler file id.', + }, + { + key: 'maxUses', + type: 'integer', + required: false, + description: 'Maximum accepted firings before exhaustion.', + }, + ], + }), + instinctSkillSchema.parse({ + key: 'createRoute', + name: 'Create Route', + description: 'Maps a slug to a component file.', + input: [ + { + key: 'slug', + type: 'string', + required: true, + description: 'Route slug.', + }, + { + key: 'file', + type: 'file', + required: true, + description: 'Component file id.', + }, + ], + }), +]; + +export function referenceInstinctSkill(key: string) { + // + return instinctSkills.find((skill) => skill.key === key); +} diff --git a/apps/meseeks/lib/reactor/runtimeAdapters.ts b/apps/meseeks/lib/reactor/runtimeAdapters.ts new file mode 100644 index 00000000..73c7bd1d --- /dev/null +++ b/apps/meseeks/lib/reactor/runtimeAdapters.ts @@ -0,0 +1,364 @@ +import { z } from 'zod/v3'; +import { + type IntelligenceAdapter, + type IntelligenceRunInput, + type IntelligenceRunResult, + type ObjectReadRange, + type ObjectStorageAdapter, + type SandboxAdapter, + type SandboxRunInput, + type TriggerIsolateAdapter, + type TriggerIsolateInput, + type TriggerIsolateResult, + triggerProposalListSchema, +} from './adapters'; + +const daytonaExecuteResultSchema = z.object({ + stdout: z.string().default(''), + stderr: z.string().default(''), + exitCode: z.number().int(), + metadata: z.record(z.unknown()).default({}), +}); + +const quickJsResultSchema = z.object({ + proposals: triggerProposalListSchema.default([]), + metadata: z.record(z.unknown()).default({}), +}); + +const statelessIntelligenceResultSchema = z.object({ + text: z.string().default(''), + costs: z + .array( + z.object({ + symbol: z.literal('USD'), + amount: z.bigint(), + description: z.string().min(1), + }), + ) + .default([]), + providerItems: z.array(z.record(z.unknown())).default([]), + reasoningSummaries: z.array(z.string()).default([]), + metadata: z.record(z.unknown()).default({}), +}); + +export type ReactorFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise; + +export type S3ObjectStorageConfig = { + endpoint: string; + bucket: string; + accessKeyId: string; + secretAccessKey: string; + region: string; + prefix?: string; + fetch?: ReactorFetch; + now?: () => Date; +}; + +export function createS3ObjectStorageAdapter(config: S3ObjectStorageConfig): ObjectStorageAdapter { + // + const runFetch = config.fetch ?? fetch; + const now = config.now ?? (() => new Date()); + + return { + async read(pointer) { + if (pointer.kind !== 'object') throw new Error('Object storage can only read object content pointers.'); + + const key = scopedKey(config.prefix, pointer.storageKey); + const response = await signedFetch({ + config, + method: 'GET', + key, + body: new Uint8Array(), + runFetch, + now, + }); + if (!response.ok) throw new Error(`Object read failed with ${response.status}.`); + + return new Uint8Array(await response.arrayBuffer()); + }, + async readRange(pointer, range) { + if (pointer.kind !== 'object') throw new Error('Object storage can only read object content pointers.'); + + const key = scopedKey(config.prefix, pointer.storageKey); + const response = await signedFetch({ + config, + method: 'GET', + key, + body: new Uint8Array(), + range: rangeHeader(range), + runFetch, + now, + }); + if (!response.ok && response.status !== 206) { + throw new Error(`Object range read failed with ${response.status}.`); + } + + return new Uint8Array(await response.arrayBuffer()); + }, + async write(input) { + const key = crypto.randomUUID(); + const response = await signedFetch({ + config, + method: 'PUT', + key: scopedKey(config.prefix, key), + body: input.bytes, + contentType: input.contentType, + runFetch, + now, + }); + if (!response.ok) throw new Error(`Object write failed with ${response.status}.`); + + return { + key, + contentType: input.contentType, + size: input.bytes.byteLength, + }; + }, + async delete(key) { + const response = await signedFetch({ + config, + method: 'DELETE', + key: scopedKey(config.prefix, key), + body: new Uint8Array(), + runFetch, + now, + }); + if (!response.ok && response.status !== 404) { + throw new Error(`Object delete failed with ${response.status}.`); + } + }, + }; +} + +export type DaytonaDriver = { + id: string; + writeFile(path: string, content: Uint8Array): Promise; + execute(command: string, options: { env: Record; timeoutMs: number }): Promise; + readFile(path: string): Promise; + close(): Promise; +}; + +export type DaytonaSandboxConfig = { + create(input: SandboxRunInput): Promise; + declaredOutputPaths?: (input: SandboxRunInput) => string[]; +}; + +export function createDaytonaSandboxAdapter(config: DaytonaSandboxConfig): SandboxAdapter { + // + return { + async run(input) { + const sandbox = await config.create(input); + + try { + for (const file of input.files) { + await sandbox.writeFile(file.path, file.content); + } + + const rawResult = await sandbox.execute(input.command, { + env: input.env, + timeoutMs: input.timeoutMs, + }); + const result = daytonaExecuteResultSchema.parse(rawResult); + const outputPaths = config.declaredOutputPaths?.(input) ?? []; + const declaredOutputs = []; + + for (const path of outputPaths) { + const bytes = await sandbox.readFile(path); + declaredOutputs.push({ path, bytes }); + } + + return { + runId: sandbox.id, + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + declaredOutputs, + metadata: result.metadata, + }; + } finally { + await sandbox.close(); + } + }, + async cancel() { + // provider-specific cancellation will use the recorded run id once reusable sandboxes land. + }, + }; +} + +export type QuickJsDriver = { + evaluate(input: { code: string; context: Record; timeoutMs: number }): Promise; +}; + +export function createQuickJsTriggerIsolateAdapter(driver: QuickJsDriver): TriggerIsolateAdapter { + // + return { + async evaluate(input: TriggerIsolateInput): Promise { + const raw = await driver.evaluate({ + code: input.code, + context: input.context, + timeoutMs: input.timeoutMs, + }); + const parsed = quickJsResultSchema.parse(raw); + + return { + proposals: parsed.proposals, + metadata: parsed.metadata, + }; + }, + }; +} + +export type StatelessIntelligenceDriver = { + run(input: IntelligenceRunInput & { store: false }): Promise; +}; + +export function createStatelessIntelligenceAdapter(driver: StatelessIntelligenceDriver): IntelligenceAdapter { + // + return { + async run(input: IntelligenceRunInput): Promise { + const raw = await driver.run({ + ...input, + store: false, + }); + return statelessIntelligenceResultSchema.parse(raw); + }, + }; +} + +async function signedFetch(args: { + config: S3ObjectStorageConfig; + method: 'DELETE' | 'GET' | 'PUT'; + key: string; + body: Uint8Array; + contentType?: string; + range?: string; + runFetch: ReactorFetch; + now: () => Date; +}) { + // + const url = objectUrl(args.config, args.key); + const timestamp = awsTimestamp(args.now()); + const date = timestamp.slice(0, 8); + const payloadHash = await sha256Hex(args.body); + const host = url.host; + const headers = new Headers({ + host, + 'x-amz-content-sha256': payloadHash, + 'x-amz-date': timestamp, + }); + if (args.contentType) headers.set('content-type', args.contentType); + if (args.range) headers.set('range', args.range); + + const signedHeaders = Array.from(headers.keys()).sort().join(';'); + const canonicalHeaders = Array.from(headers.keys()) + .sort() + .map((name) => `${name}:${headers.get(name) ?? ''}\n`) + .join(''); + const canonicalRequest = [ + args.method, + url.pathname, + url.searchParams.toString(), + canonicalHeaders, + signedHeaders, + payloadHash, + ].join('\n'); + const credentialScope = `${date}/${args.config.region}/s3/aws4_request`; + const stringToSign = [ + 'AWS4-HMAC-SHA256', + timestamp, + credentialScope, + await sha256Hex(new TextEncoder().encode(canonicalRequest)), + ].join('\n'); + const signingKey = await awsSigningKey({ + secretAccessKey: args.config.secretAccessKey, + date, + region: args.config.region, + }); + const signature = await hmacHex(signingKey, stringToSign); + const authorization = [ + `AWS4-HMAC-SHA256 Credential=${args.config.accessKeyId}/${credentialScope}`, + `SignedHeaders=${signedHeaders}`, + `Signature=${signature}`, + ].join(', '); + + headers.set('authorization', authorization); + + return await args.runFetch(url, { + method: args.method, + headers, + body: args.method === 'PUT' ? arrayBufferFromBytes(args.body) : undefined, + }); +} + +function rangeHeader(range: ObjectReadRange) { + // + if ('suffixLength' in range) return `bytes=-${range.suffixLength}`; + + const end = range.offset + range.length - 1; + return `bytes=${range.offset}-${end}`; +} + +function objectUrl(config: S3ObjectStorageConfig, key: string) { + // + const url = new URL(config.endpoint); + url.pathname = `/${encodePathPart(config.bucket)}/${key.split('/').map(encodePathPart).join('/')}`; + return url; +} + +function scopedKey(prefix: string | undefined, key: string) { + // + if (prefix === undefined) return key; + if (prefix === '') return key; + + return `${prefix.replace(/\/+$/, '')}/${key.replace(/^\/+/, '')}`; +} + +function encodePathPart(value: string) { + return encodeURIComponent(value).replace(/%2F/g, '/'); +} + +function awsTimestamp(date: Date) { + return date.toISOString().replace(/[:-]|\.\d{3}/g, ''); +} + +async function sha256Hex(input: Uint8Array) { + const hash = await crypto.subtle.digest('SHA-256', arrayBufferFromBytes(input)); + return hex(new Uint8Array(hash)); +} + +async function awsSigningKey(args: { secretAccessKey: string; date: string; region: string }) { + // + const dateKey = await hmacBytes(new TextEncoder().encode(`AWS4${args.secretAccessKey}`), args.date); + const dateRegionKey = await hmacBytes(dateKey, args.region); + const dateRegionServiceKey = await hmacBytes(dateRegionKey, 's3'); + return await hmacBytes(dateRegionServiceKey, 'aws4_request'); +} + +async function hmacBytes(keyBytes: Uint8Array, value: string) { + const key = await crypto.subtle.importKey( + 'raw', + arrayBufferFromBytes(keyBytes), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ); + const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(value)); + return new Uint8Array(signature); +} + +async function hmacHex(keyBytes: Uint8Array, value: string) { + return hex(await hmacBytes(keyBytes, value)); +} + +function hex(bytes: Uint8Array) { + return Array.from(bytes) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); +} + +function arrayBufferFromBytes(bytes: Uint8Array) { + // + const copy: Uint8Array = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return copy.buffer; +} diff --git a/apps/meseeks/lib/triggerIsolate.test.ts b/apps/meseeks/lib/triggerIsolate.test.ts new file mode 100644 index 00000000..c49403a5 --- /dev/null +++ b/apps/meseeks/lib/triggerIsolate.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test } from 'bun:test'; +import { managedTriggerHandlers } from './proDefinitions'; +import { evaluateTriggerCode } from './triggerIsolate'; + +describe('trigger isolate', () => { + test('runs managed Ask and Seek handler registrations', async () => { + const ask = await evaluateTriggerCode({ + code: managedHandlerCode('@pro/triggers/Ask'), + context: { + action: { + skillKey: 'say', + }, + }, + timeoutMs: 1000, + }); + expect(ask.proposals).toEqual([ + { + skillKey: 'think', + args: { + mode: 'reply', + }, + }, + ]); + + const ignoredAsk = await evaluateTriggerCode({ + code: managedHandlerCode('@pro/triggers/Ask'), + context: { + action: { + skillKey: 'plan', + }, + }, + timeoutMs: 1000, + }); + expect(ignoredAsk.proposals).toEqual([]); + + const seekPlan = await evaluateTriggerCode({ + code: managedHandlerCode('@pro/triggers/Seek'), + context: { + action: { + skillKey: 'say', + }, + }, + timeoutMs: 1000, + }); + expect(seekPlan.proposals).toEqual([ + { + skillKey: 'plan', + args: {}, + }, + ]); + + const seekIterate = await evaluateTriggerCode({ + code: managedHandlerCode('@pro/triggers/Seek'), + context: { + action: { + skillKey: 'plan', + }, + }, + timeoutMs: 1000, + }); + expect(seekIterate.proposals).toEqual([ + { + skillKey: 'iterate', + args: { + maxDepth: 8, + iteration: 1, + }, + }, + ]); + + const seekContinue = await evaluateTriggerCode({ + code: managedHandlerCode('@pro/triggers/Seek'), + context: { + action: { + skillKey: 'iterate', + args: { + iteration: 2, + }, + depth: 2, + result: { + metadata: { + seekState: 'continue', + }, + }, + }, + }, + timeoutMs: 1000, + }); + expect(seekContinue.proposals).toEqual([ + { + skillKey: 'iterate', + args: { + maxDepth: 8, + iteration: 3, + }, + }, + ]); + }); + + test('rejects malformed proposals', async () => { + await expect( + evaluateTriggerCode({ + code: '() => ({ proposals: [{ args: {} }] })', + context: {}, + timeoutMs: 1000, + }), + ).rejects.toThrow(); + }); + + test('does not expose network, process, require, or ambient secrets', async () => { + const result = await evaluateTriggerCode({ + code: `() => ({ + proposals: [{ + skillKey: "think", + args: { + hasFetch: typeof fetch !== "undefined", + hasProcess: typeof process !== "undefined", + hasRequire: typeof require !== "undefined", + hasSecret: typeof DAYTONA_API_KEY !== "undefined" || typeof OPENAI_API_KEY !== "undefined", + }, + }], + })`, + context: {}, + timeoutMs: 1000, + }); + + expect(result.proposals[0]?.args).toEqual({ + hasFetch: false, + hasProcess: false, + hasRequire: false, + hasSecret: false, + }); + }); +}); + +function managedHandlerCode(ref: string) { + // + const handler = managedTriggerHandlers.find((candidate) => candidate.key === ref); + if (!handler) throw new Error(`Missing managed trigger handler ${ref}`); + + return handler.body; +} diff --git a/apps/meseeks/lib/triggerIsolate.ts b/apps/meseeks/lib/triggerIsolate.ts new file mode 100644 index 00000000..c6ff4111 --- /dev/null +++ b/apps/meseeks/lib/triggerIsolate.ts @@ -0,0 +1,84 @@ +import RELEASE_SYNC from '@jitl/quickjs-singlefile-cjs-release-sync'; +import { newQuickJSWASMModuleFromVariant, type QuickJSWASMModule } from 'quickjs-emscripten-core'; +import { z } from 'zod/v3'; +import { createQuickJsTriggerIsolateAdapter } from './reactor/runtimeAdapters'; + +const proposalSchema = z.object({ + skillKey: z.string().min(1), + args: z.record(z.unknown()).default({}), + loop: z.string().min(1).nullable().optional(), +}); + +const isolateResultSchema = z.object({ + proposals: z.array(proposalSchema).default([]), + metadata: z.record(z.unknown()).default({}), +}); + +export type TriggerIsolateContext = Record; +export type TriggerIsolateEvaluation = z.infer; + +let quickJsModule: Promise | undefined; + +export async function evaluateTriggerCode(args: { + code: string; + context: TriggerIsolateContext; + timeoutMs: number; +}): Promise { + // + const adapter = createQuickJsTriggerIsolateAdapter({ + evaluate: async (input) => + await evaluateQuickJs({ + code: input.code, + context: input.context, + timeoutMs: input.timeoutMs, + }), + }); + + return isolateResultSchema.parse(await adapter.evaluate(args)); +} + +async function evaluateQuickJs(args: { code: string; context: TriggerIsolateContext; timeoutMs: number }) { + // + const QuickJS = await loadQuickJs(); + const vm = QuickJS.newContext(); + const contextJson = JSON.stringify(args.context, jsonReplacer); + const code = [ + `const context = ${contextJson};`, + `const handler = (${args.code});`, + `if (typeof handler !== "function") throw new Error("Trigger handler must be a function.");`, + `const result = handler(context);`, + `result;`, + ].join('\n'); + + const timeout = setTimeout(() => { + vm.runtime.dispose(); + }, args.timeoutMs); + + try { + const result = vm.evalCode(code); + if (result.error) { + const error = vm.dump(result.error); + result.error.dispose(); + throw new Error(typeof error === 'string' ? error : 'Trigger isolate failed.'); + } + + const value = vm.dump(result.value); + result.value.dispose(); + return value; + } finally { + clearTimeout(timeout); + vm.dispose(); + } +} + +function loadQuickJs() { + // + quickJsModule ??= newQuickJSWASMModuleFromVariant(RELEASE_SYNC); + return quickJsModule; +} + +function jsonReplacer(_key: string, value: unknown) { + // + if (typeof value === 'bigint') return value.toString(); + return value; +} diff --git a/apps/meseeks/package.json b/apps/meseeks/package.json index ba19400b..dfe46d8c 100644 --- a/apps/meseeks/package.json +++ b/apps/meseeks/package.json @@ -26,25 +26,16 @@ "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", "@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/react-query": "^0.0.0-alpha.8", + "@daytona/sdk": "^0.184.0", "@hookform/resolvers": "4.1.3", + "@jitl/quickjs-singlefile-cjs-release-sync": "^0.32.0", "@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,16 +46,15 @@ "@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", + "quickjs-emscripten-core": "^0.32.0", "react": "^19.2.3", "react-dom": "^19.2.3", "react-hook-form": "^7.55.0", @@ -73,6 +63,7 @@ "sonner": "^1.7.1", "standardwebhooks": "^1.0.0", "tailwindcss-animate": "^1.0.7", + "tslib": "^2.8.1", "use-stick-to-bottom": "^1.0.45", "vite": "^7.1.7", "zod": "^3.25.76", diff --git a/apps/meseeks/public/static/privacy-policy.md b/apps/meseeks/public/static/privacy-policy.md index fa4aba17..f2422be6 100644 --- a/apps/meseeks/public/static/privacy-policy.md +++ b/apps/meseeks/public/static/privacy-policy.md @@ -1,19 +1,19 @@ -# Privacy Policy for Meseeks +# Privacy Policy for PRO Last Updated: May 2, 2025 -Meseeks, a product of **IgorSilvaPro OÜ**, headquartered in Tallinn, Estonia (“we”, “us”, “our”), respects your privacy and is committed to protecting your personal data. This Privacy Policy explains how we collect, use, disclose, and safeguard your information when you use our mobile application “Meseeks” (the “App”). This policy is required by Google and applies to all users of the App. +PRO, a product of **IgorSilvaPro OÜ**, headquartered in Tallinn, Estonia (“we”, “us”, “our”), respects your privacy and is committed to protecting your personal data. This Privacy Policy explains how we collect, use, disclose, and safeguard your information when you use our mobile application “PRO” (the “App”). This policy is required by Google and applies to all users of the App. ## Research Preview and Limited Liability -> **Important Notice:** Meseeks is currently offered as a **RESEARCH PREVIEW**. The App and its underlying AI systems are experimental and may change or be discontinued at any time. We provide the App on an **"AS IS"** and **"AS AVAILABLE"** basis, without warranties of any kind, and our liability is limited to the fullest extent permitted by applicable law. +> **Important Notice:** PRO is currently offered as a **RESEARCH PREVIEW**. The App and its underlying AI systems are experimental and may change or be discontinued at any time. We provide the App on an **"AS IS"** and **"AS AVAILABLE"** basis, without warranties of any kind, and our liability is limited to the fullest extent permitted by applicable law. ### User Responsibilities - **Do not share sensitive, confidential, or personal data** with the App. Treat all inputs as potentially public and non‑secure. - **Maintain your own backups.** Do not rely on the App as the sole repository for important information or content. - **Validate critical outputs.** Responses may contain errors, be incomplete, or become outdated quickly. Always verify crucial information independently before acting on it. -- **Use at your own risk.** By continuing to use the App 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 the App. +- **Use at your own risk.** By continuing to use the App during this research phase, you acknowledge these limitations and agree that PRO shall not be liable for any loss, damage, or harm arising from your reliance on the App. ## 1. Information We Collect @@ -33,9 +33,9 @@ We automatically collect information about how you interact with the App, includ * Crash reports and performance data * Log information such as IP address, device identifiers, and timestamps -### 1.3 Analytics and Cookies +### 1.3 Diagnostics and Cookies -We use Vercel Analytics to understand usage patterns and improve the App. This service may place cookies or similar technologies on your device to track usage. +We may use error and performance diagnostics to understand crashes, latency, and reliability issues. These services may place cookies or similar technologies on your device where required for diagnostics. ## 2. How We Use Your Information @@ -86,4 +86,3 @@ We may update this policy from time to time. We will notify you of any material If you have questions or concerns about this Privacy Policy, please contact us at: * **Email:** [privacy@igorsilva.pro](mailto:privacy@igorsilva.pro) -* **Twitter:** [@MeseeksApp](https://x.com/MeseeksApp) diff --git a/apps/meseeks/public/static/site.webmanifest b/apps/meseeks/public/static/site.webmanifest index a016ca46..07e8c42c 100644 --- a/apps/meseeks/public/static/site.webmanifest +++ b/apps/meseeks/public/static/site.webmanifest @@ -1,9 +1,9 @@ { - "name": "Meseeks", - "short_name": "Meseeks", - "description": "AI-powered engine.", + "name": "PRO", + "short_name": "PRO", + "description": "your Personal Relentless Operator.", "start_url": "/", - "id": "?pro.igorsilva.meseeks=v0", + "id": "?pro.igorsilva.pro=v0", "orientation": "any", "display": "fullscreen", "lang": "en", @@ -36,4 +36,4 @@ "purpose": "any" } ] -} \ No newline at end of file +} diff --git a/apps/meseeks/public/static/terms-of-use.md b/apps/meseeks/public/static/terms-of-use.md index 2d96e6a8..095e9ac3 100644 --- a/apps/meseeks/public/static/terms-of-use.md +++ b/apps/meseeks/public/static/terms-of-use.md @@ -1,14 +1,14 @@ -# Terms of Service for Meseeks +# Terms of Service for PRO Last Updated: May 2, 2025 -Welcome to **Meseeks** (the “App”), provided by IgorSilvaPro OÜ (“Meseeks,” “we,” “our,” or “us”). These Terms of Service (the “Terms”) govern your access to and use of the App and any related services (collectively, the “Services”). By downloading, installing, or using the App, **you agree to be bound by these Terms**. If you do not agree, do not use the App. +Welcome to **PRO** (the “App”), provided by IgorSilvaPro OÜ (“PRO,” “we,” “our,” or “us”). These Terms of Service (the “Terms”) govern your access to and use of the App and any related services (collectively, the “Services”). By downloading, installing, or using the App, **you agree to be bound by these Terms**. If you do not agree, do not use the App. --- ## 1. IMPORTANT NOTICE — Research Preview -Meseeks is currently offered as a **RESEARCH PREVIEW**. The App and its underlying AI systems are experimental and may change, evolve, or be discontinued without notice. The Services are provided **“AS IS”** and **“AS AVAILABLE,”** **without warranties of any kind**, and our liability is limited to the fullest extent permitted by applicable law (see Sections 11 and 12). +PRO is currently offered as a **RESEARCH PREVIEW**. The App and its underlying AI systems are experimental and may change, evolve, or be discontinued without notice. The Services are provided **“AS IS”** and **“AS AVAILABLE,”** **without warranties of any kind**, and our liability is limited to the fullest extent permitted by applicable law (see Sections 11 and 12). **User Responsibilities (Summary)** - **Do not share sensitive, confidential, or personal data** with the App. @@ -28,9 +28,9 @@ Meseeks is currently offered as a **RESEARCH PREVIEW**. The App and its underlyi ## 3. Limited License -Subject to your compliance with these Terms and except as set forth below, Meseeks grants you a personal, revocable, non‑exclusive, non‑transferable, and non‑sublicensable license to download, install, and use the App for lawful, personal, and non‑commercial purposes. +Subject to your compliance with these Terms and except as set forth below, PRO grants you a personal, revocable, non‑exclusive, non‑transferable, and non‑sublicensable license to download, install, and use the App for lawful, personal, and non‑commercial purposes. -**Open‑Source Exception.** The App’s source code is publicly available and licensed under the **GNU Affero General Public License v3.0 (AGPL‑3.0)** (or any successor license specified in the source repository). Your rights to copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the source code are governed solely by that license. Nothing in these Terms is intended to limit or restrict the rights granted to you under the AGPL‑3.0. Trademarks, service marks, and brand assets of Meseeks are **not** licensed under the AGPL‑3.0 and may not be used without our prior written permission. +**Open‑Source Exception.** The App’s source code is publicly available and licensed under the **GNU Affero General Public License v3.0 (AGPL‑3.0)** (or any successor license specified in the source repository). Your rights to copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the source code are governed solely by that license. Nothing in these Terms is intended to limit or restrict the rights granted to you under the AGPL‑3.0. Trademarks, service marks, and brand assets of PRO are **not** licensed under the AGPL‑3.0 and may not be used without our prior written permission. --- @@ -60,26 +60,26 @@ You agree not to: ## 6. Your Content - **Your Content.** “Content” means any text, images, data, or other materials you submit to or through the Services. You retain ownership of Your Content. -- **License to Us.** By submitting Content, you grant Meseeks a worldwide, royalty‑free, sublicensable license to use, reproduce, modify, and display Your Content for the purpose of operating and improving the Services. +- **License to Us.** By submitting Content, you grant PRO a worldwide, royalty‑free, sublicensable license to use, reproduce, modify, and display Your Content for the purpose of operating and improving the Services. - **Responsibility.** You are solely responsible for Your Content and the consequences of posting or transmitting it. --- ## 7. Intellectual‑Property Rights -All Meseeks logos, trademarks, service marks, and other brand identifiers (“Marks”) are and will remain the exclusive property of Meseeks and its licensors. The App’s source code is licensed under the GNU Affero General Public License v3.0 (AGPL‑3.0), subject to its terms. Except for rights expressly granted to you in the AGPL‑3.0 or elsewhere in these Terms, no right, title, or interest in the Marks or any other Meseeks intellectual property is transferred to you. +All PRO logos, trademarks, service marks, and other brand identifiers (“Marks”) are and will remain the exclusive property of PRO and its licensors. The App’s source code is licensed under the GNU Affero General Public License v3.0 (AGPL‑3.0), subject to its terms. Except for rights expressly granted to you in the AGPL‑3.0 or elsewhere in these Terms, no right, title, or interest in the Marks or any other PRO intellectual property is transferred to you. --- ## 8. Feedback -If you choose to provide feedback, suggestions, or ideas (“Feedback”), you grant Meseeks a perpetual, irrevocable, sublicensable, worldwide, royalty‑free license to use and exploit such Feedback without any obligation to you. +If you choose to provide feedback, suggestions, or ideas (“Feedback”), you grant PRO a perpetual, irrevocable, sublicensable, worldwide, royalty‑free license to use and exploit such Feedback without any obligation to you. --- ## 9. Third‑Party Services and Links -The Services may contain links to third‑party websites or services. Meseeks is not responsible for the content, policies, or practices of those third parties. Your interactions with third‑party services are solely between you and the relevant third party. +The Services may contain links to third‑party websites or services. PRO is not responsible for the content, policies, or practices of those third parties. Your interactions with third‑party services are solely between you and the relevant third party. --- @@ -89,19 +89,19 @@ YOU EXPRESSLY UNDERSTAND AND AGREE THAT: - YOUR USE OF THE SERVICES IS AT YOUR SOLE RISK. - THE SERVICES ARE PROVIDED “AS IS” AND “AS AVAILABLE,” WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, WHETHER EXPRESS, IMPLIED, OR STATUTORY. -- MESEEKS AND ITS LICENSORS EXPRESSLY DISCLAIM ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, QUIET ENJOYMENT, OR NON‑INFRINGEMENT. +- PRO AND ITS LICENSORS EXPRESSLY DISCLAIM ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, QUIET ENJOYMENT, OR NON‑INFRINGEMENT. --- ## 11. Limitation of Liability -TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, MESEEKS AND ITS AFFILIATES, OFFICERS, EMPLOYEES, AGENTS, OR LICENSORS SHALL NOT BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR ANY LOSS OF PROFITS OR DATA, ARISING OUT OF OR IN CONNECTION WITH YOUR USE OF THE SERVICES, EVEN IF MESEEKS HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. IN ANY CASE, MESEEKS’ AGGREGATE LIABILITY SHALL NOT EXCEED THE GREATER OF (A) THE AMOUNT YOU PAID FOR THE SERVICES (IF ANY) IN THE SIX MONTHS PRECEDING THE CLAIM OR (B) FIFTY EUROS (€50). +TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, PRO AND ITS AFFILIATES, OFFICERS, EMPLOYEES, AGENTS, OR LICENSORS SHALL NOT BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR ANY LOSS OF PROFITS OR DATA, ARISING OUT OF OR IN CONNECTION WITH YOUR USE OF THE SERVICES, EVEN IF PRO HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. IN ANY CASE, PRO’S AGGREGATE LIABILITY SHALL NOT EXCEED THE GREATER OF (A) THE AMOUNT YOU PAID FOR THE SERVICES (IF ANY) IN THE SIX MONTHS PRECEDING THE CLAIM OR (B) FIFTY EUROS (€50). --- ## 12. Indemnification -You agree to defend, indemnify, and hold harmless Meseeks and its officers, directors, employees, and agents from and against any claims, liabilities, damages, losses, and expenses (including reasonable legal fees) arising out of or in any way connected with: (a) Your Content; (b) your breach of these Terms; or (c) your violation of any law or the rights of any third party. +You agree to defend, indemnify, and hold harmless PRO and its officers, directors, employees, and agents from and against any claims, liabilities, damages, losses, and expenses (including reasonable legal fees) arising out of or in any way connected with: (a) Your Content; (b) your breach of these Terms; or (c) your violation of any law or the rights of any third party. --- @@ -133,7 +133,7 @@ If any provision of these Terms is found to be invalid or unenforceable, the rem ## 17. Entire Agreement -These Terms, together with our Privacy Policy, constitute the entire agreement between you and Meseeks regarding the Services and supersede any prior agreements or understandings. +These Terms, together with our Privacy Policy, constitute the entire agreement between you and PRO regarding the Services and supersede any prior agreements or understandings. --- @@ -141,8 +141,7 @@ These Terms, together with our Privacy Policy, constitute the entire agreement b If you have questions about these Terms, please contact us at: -- **Email:** privacy@igorsilva.pro -- **Twitter:** [@MeseeksApp](https://x.com/MeseeksApp) +- **Email:** privacy@igorsilva.pro --- @@ -151,5 +150,5 @@ If you have questions about these Terms, please contact us at: 1. **Source Code Availability.** The complete source code for the App is available at (the “Repository”), or any successor repository we designate. 2. **License.** The source code is licensed under the GNU Affero General Public License v3.0 (AGPL‑3.0) (see the LICENSE file in the Repository). These Terms govern your use of the Services; the AGPL‑3.0 governs your use of the source code itself. 3. **Contributions.** By submitting pull requests or other contributions to the Repository, you agree that your contributions are licensed under the AGPL‑3.0 and that you have the necessary rights to do so. -4. **No Trademark License.** The AGPL‑3.0 does not grant permission to use Meseeks trademarks, service marks, or logos. Such use requires our prior written consent. +4. **No Trademark License.** The AGPL‑3.0 does not grant permission to use PRO trademarks, service marks, or logos. Such use requires our prior written consent. 5. **Conflict.** In the event of any conflict between these Terms and the AGPL‑3.0 with respect to the source code, the AGPL‑3.0 shall control. diff --git a/apps/meseeks/public/static/translate.webmanifest b/apps/meseeks/public/static/translate.webmanifest index da4b1470..836ffe0d 100644 --- a/apps/meseeks/public/static/translate.webmanifest +++ b/apps/meseeks/public/static/translate.webmanifest @@ -4,7 +4,7 @@ "description": "Live speech translation.", "start_url": "/translate", "scope": "/", - "id": "/translate?pro.igorsilva.meseeks.translate=v0", + "id": "/translate?pro.igorsilva.pro.translate=v0", "orientation": "any", "display": "fullscreen", "lang": "en", diff --git a/apps/meseeks/schemas/actionDetailSchema.tsx b/apps/meseeks/schemas/actionDetailSchema.tsx index d4d55676..2709103f 100644 --- a/apps/meseeks/schemas/actionDetailSchema.tsx +++ b/apps/meseeks/schemas/actionDetailSchema.tsx @@ -1,162 +1,76 @@ import { zid } from 'convex-helpers/server/zod3'; import { z } from 'zod/v3'; -import { skillKindSchema } from './skillSchema'; +import { actionWarningSchema, costSchema } from './actionSchema'; -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()), +const coreDetailSchema = z.object({ + action: zid('actions'), + createdAt: z.number(), + updatedAt: 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'); - -// 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'), - - // 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'), - - // execution results - finishReason: z - .string() - .optional() - .describe('Why the model stopped generating (stop, length, tool-calls, etc.)'), - - text: z.string().optional().describe('Direct text response from the model'), - - toolCalls: z.array(toolCallSchema).optional().describe('List of tool calls made by the model'), - - // comprehensive usage statistics - usage: tokenUsageSchema.optional().describe('Detailed token usage breakdown for cost analysis'), - - // warnings and metadata (preserved as-is for debugging) - warnings: z.array(z.unknown()).optional().describe('AI SDK warnings (can be complex objects)'), +export const modelDetailSchema = coreDetailSchema.extend({ + kind: z.literal('model'), + skill: zid('skills').optional(), + skillFile: zid('files').optional(), + provider: z.string().min(1), + model: z.string().min(1), + input: z.unknown().optional(), + output: z.unknown().optional(), + metadata: z.record(z.unknown()).optional(), + reasoningFile: zid('files').optional(), + usage: z.unknown().optional(), + costs: z.array(costSchema).default([]), + warnings: z.array(actionWarningSchema).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 requestDetailSchema = coreDetailSchema.extend({ + kind: z.literal('request'), + skill: zid('skills').optional(), + skillFile: zid('files').optional(), + url: z.string().min(1), + method: z.string().min(1), + status: z.number().int().optional(), + requestFile: zid('files').optional(), + responseFile: zid('files').optional(), + costs: z.array(costSchema).default([]), + warnings: z.array(actionWarningSchema).optional(), +}); -export const actionDetailSchema = z - .union([httpActionDetailSchema, llmActionDetailSchema]) - .describe('Complete action execution details for debugging and transparency'); +export const executionDetailSchema = coreDetailSchema.extend({ + kind: z.literal('execution'), + skill: zid('skills').optional(), + skillFile: zid('files').optional(), + box: zid('boxes').optional(), + command: z.string().min(1).optional(), + stdoutFile: zid('files').optional(), + stderrFile: zid('files').optional(), + outputFiles: z.array(zid('files')).default([]), + exitCode: z.number().int().optional(), + costs: z.array(costSchema).default([]), + warnings: z.array(actionWarningSchema).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 mutationDetailSchema = coreDetailSchema.extend({ + kind: z.literal('mutation'), + skill: zid('skills').optional(), + summary: z.string().min(1), + resultFile: zid('files').optional(), + metadata: z.record(z.unknown()).optional(), + warnings: z.array(actionWarningSchema).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 warningDetailSchema = coreDetailSchema.extend({ + kind: z.literal('warning'), + key: z.string().min(1), + severity: z.enum(['info', 'warning', 'error']), + message: z.string().min(1), + source: z.enum(['claim', 'perform', 'settle', 'runtime']), }); -// Update schema - only mutable fields can be updated -export const actionDetailUpdateSchema = z.union([ - z.object({ - llm: llmUpdateFields, - }), - z.object({ - http: httpUpdateFields, - }), +export const actionDetailSchema = z.union([ + modelDetailSchema, + requestDetailSchema, + executionDetailSchema, + mutationDetailSchema, + warningDetailSchema, ]); diff --git a/apps/meseeks/schemas/actionSchema.ts b/apps/meseeks/schemas/actionSchema.ts new file mode 100644 index 00000000..ab849f90 --- /dev/null +++ b/apps/meseeks/schemas/actionSchema.ts @@ -0,0 +1,66 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import { authorSchema } from './authorSchema'; +import { tokenSchema } from './topUpSchema'; + +export const actionStatusSchema = z.enum([ + 'pending authorization', + 'enqueued', + 'running', + 'succeeded', + 'failed', + 'skipped', + 'interrupted', +]); + +export const costSchema = z.object({ + symbol: tokenSchema, + amount: z.bigint(), + description: z.string().min(1), +}); + +export const actionResultFileSchema = z.object({ + file: zid('files'), + path: z.string().min(1), + size: z.number().int().nonnegative().optional(), + contentType: z.string().min(1).optional(), +}); + +export const actionResultSchema = z.object({ + text: z.string().optional(), + files: z.array(actionResultFileSchema).default([]), + metadata: z.record(z.unknown()).optional(), +}); + +export const actionWarningSchema = z.object({ + key: z.string().min(1), + severity: z.enum(['info', 'warning', 'error']), + message: z.string().min(1), + source: z.enum(['claim', 'perform', 'settle']), + createdAt: z.number(), +}); + +export const actionSchema = z.object({ + owner: zid('users'), + file: zid('files'), + index: z.number().int().nonnegative(), + depth: z.number().int().nonnegative(), + spark: zid('actions').optional(), + author: authorSchema, + skillKey: z.string().min(1), + loopKey: z.string().min(1).optional(), + intelligenceKey: z.string().min(1).optional(), + args: z.record(z.unknown()), + status: actionStatusSchema, + resultFile: zid('files').optional(), + costs: z.array(costSchema).default([]), + expectedCost: z.bigint().optional(), + maxCost: z.bigint().optional(), + reservedBudget: z.bigint().optional(), + claimedAt: z.number().optional(), + startedAt: z.number().optional(), + settledAt: z.number().optional(), + authorizedAt: z.number().optional(), + interruptedAt: z.number().optional(), + createdAt: z.number(), +}); diff --git a/apps/meseeks/schemas/actionSchema.tsx b/apps/meseeks/schemas/actionSchema.tsx deleted file mode 100644 index 983af7f1..00000000 --- a/apps/meseeks/schemas/actionSchema.tsx +++ /dev/null @@ -1,81 +0,0 @@ -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(), -}); - -export const pendingActionStatusSchema = z.enum([ - 'pending authorization', // - 'enqueued', - 'running', -]); - -export const resolvedActionStatusSchema = z.enum([ - 'succeeded', // - 'skipped', - 'failed', -]); - -export const actionStatusSchema = pendingActionStatusSchema.or(resolvedActionStatusSchema); - -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(), - }), - ), -}); - -export const actionSchema = z - .union([ - pendingActionSchema, // - resolvedActionSchema, - ]) - .describe( - 'An Action is any occurrence within a Task.', // - ); diff --git a/apps/meseeks/schemas/authorSchema.tsx b/apps/meseeks/schemas/authorSchema.tsx index b4c817af..1e5a1720 100644 --- a/apps/meseeks/schemas/authorSchema.tsx +++ b/apps/meseeks/schemas/authorSchema.tsx @@ -5,5 +5,6 @@ export const authorSchema = z .union([ zid('users'), // zid('actions'), + zid('triggers'), ]) - .describe('The author of an action is the user, when directly executed, or the action that led to it.'); + .describe('The author is the immediate user, action, or trigger cause.'); diff --git a/apps/meseeks/schemas/boxSchema.ts b/apps/meseeks/schemas/boxSchema.ts new file mode 100644 index 00000000..f8e506b8 --- /dev/null +++ b/apps/meseeks/schemas/boxSchema.ts @@ -0,0 +1,18 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import { authorSchema } from './authorSchema'; + +export const boxStatusSchema = z.enum(['creating', 'running', 'idle', 'repairing', 'terminated', 'failed']); + +export const boxSchema = z.object({ + owner: zid('users'), + file: zid('files'), + author: authorSchema, + provider: z.string().min(1), + providerReference: z.string().min(1).optional(), + status: boxStatusSchema, + createdAt: z.number(), + updatedAt: z.number(), + lastUsedAt: z.number().optional(), + expiresAt: z.number().optional(), +}); diff --git a/apps/meseeks/schemas/componentSchema.tsx b/apps/meseeks/schemas/componentSchema.tsx index 48cf3d5e..2bc19941 100644 --- a/apps/meseeks/schemas/componentSchema.tsx +++ b/apps/meseeks/schemas/componentSchema.tsx @@ -1,20 +1,11 @@ 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(), + owner: zid('users'), + component: zid('files'), + defaultFile: zid('files').optional(), + body: z.string().max(100000).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.', - ), + slug: z.string().optional(), }); diff --git a/apps/meseeks/schemas/draftSchema.ts b/apps/meseeks/schemas/draftSchema.ts index bff36c75..9658e882 100644 --- a/apps/meseeks/schemas/draftSchema.ts +++ b/apps/meseeks/schemas/draftSchema.ts @@ -8,7 +8,7 @@ export const draftQueueItemSchema = z.object({ export const draftSchema = z.object({ owner: zid('users'), - taskId: zid('tasks'), + fileId: zid('files'), 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..7c841c84 100644 --- a/apps/meseeks/schemas/envSchema.ts +++ b/apps/meseeks/schemas/envSchema.ts @@ -47,6 +47,72 @@ export const env = createEnv({ .describe('Maximum HTTP response body size to store in action details (in bytes).') .default('819200'), // 800KiB in bytes + OBJECT_STORAGE_ENDPOINT: z + .string() + .min(1) + .optional() + .describe('S3-compatible endpoint for Reactor object storage.'), + OBJECT_STORAGE_BUCKET: z.string().min(1).optional().describe('Bucket for Reactor object storage.'), + OBJECT_STORAGE_ACCESS_KEY_ID: z + .string() + .min(1) + .optional() + .describe('S3-compatible access key for Reactor object storage.'), + OBJECT_STORAGE_SECRET_ACCESS_KEY: z + .string() + .min(1) + .optional() + .describe('S3-compatible secret key for Reactor object storage.'), + OBJECT_STORAGE_REGION: z + .string() + .min(1) + .optional() + .describe('S3-compatible region for Reactor object storage.'), + OBJECT_STORAGE_PREFIX: z + .string() + .optional() + .describe('Optional Reactor object storage key prefix. Empty means bucket root.'), + PRO_OWNER_USER_ID: z + .string() + .min(1) + .optional() + .describe('User id that owns shared public PRO loops, skills, route components, and trigger handlers.'), + R2_ENDPOINT: z.string().min(1).optional().describe('Deprecated fallback for OBJECT_STORAGE_ENDPOINT.'), + R2_BUCKET: z.string().min(1).optional().describe('Deprecated fallback for OBJECT_STORAGE_BUCKET.'), + R2_ACCESS_KEY_ID: z + .string() + .min(1) + .optional() + .describe('Deprecated fallback for OBJECT_STORAGE_ACCESS_KEY_ID.'), + R2_SECRET_ACCESS_KEY: z + .string() + .min(1) + .optional() + .describe('Deprecated fallback for OBJECT_STORAGE_SECRET_ACCESS_KEY.'), + R2_REGION: z.string().min(1).optional().describe('Deprecated fallback for OBJECT_STORAGE_REGION.'), + R2_PREFIX: z + .string() + .optional() + .describe('Deprecated fallback for OBJECT_STORAGE_PREFIX. Empty means bucket root.'), + MAX_REACTOR_OBJECT_READ_BYTES: z + .string() + .transform((s) => Number.parseInt(s, 10)) + .pipe(z.number().int().min(1)) + .default('1048576') + .describe('Maximum exact object bytes returned through the file read path.'), + MAX_REACTOR_PARTIAL_READ_BYTES: z + .string() + .transform((s) => Number.parseInt(s, 10)) + .pipe(z.number().int().min(1)) + .default('262144') + .describe('Maximum object bytes fetched for partial file rendering.'), + MAX_REACTOR_INLINE_CONTENT_BYTES: z + .string() + .transform((s) => Number.parseInt(s, 10)) + .pipe(z.number().int().min(1)) + .default('262144') + .describe('Maximum hot text bytes stored inline in Reactor file_contents.'), + AUTH_GOOGLE_ID: z.string().min(1).describe('Google OAuth client ID.'), AUTH_GOOGLE_SECRET: z.string().min(1).describe('Google OAuth client secret.'), @@ -104,7 +170,7 @@ export const env = createEnv({ .string() .transform((s) => Number.parseInt(s, 10)) .pipe(z.number().int().min(1)) - .describe('The maximum estimated tokens to keep in model context.') + .describe('The maximum estimated tokens to keep in intelligence context.') .default('128000'), ACTION_TIMEOUT_BUFFER_MS: z @@ -118,13 +184,23 @@ export const env = createEnv({ .string() .transform((s) => Number.parseInt(s, 10)) .pipe(z.number()) - .describe('Maximum number of active tasks to show in activeTasks variable.') + .describe('Maximum number of active files to show in activeFiles variable.') .default('20'), GROQ_API_KEY: z.string().min(1).describe('Groq API key.'), + DEEPSEEK_API_KEY: z.string().min(1).optional().describe('DeepSeek API key for Reactor DeepSeek intelligences.'), + OPENAI_API_KEY: z + .string() + .min(1) + .optional() + .describe('OpenAI API key for Reactor stateless intelligence calls.'), + DAYTONA_API_KEY: z.string().min(1).optional().describe('Daytona API key for Reactor sandbox execution.'), + DAYTONA_API_URL: z.string().url().optional().describe('Daytona API URL for Reactor sandbox execution.'), + DAYTONA_TARGET: z.string().min(1).optional().describe('Daytona target for Reactor sandbox execution.'), + DAYTONA_IMAGE: z.string().min(1).optional().describe('Daytona image for Reactor sandbox execution.'), 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.'), + MOONSHOT_API_KEY: z.string().min(1).optional().describe('Moonshot API key for Reactor Kimi intelligences.'), NODE_ENV: z.enum(['development', 'production']).default('development').describe('Automatically populated.'), }, @@ -143,3 +219,19 @@ export const env = createEnv({ */ emptyStringAsUndefined: true, }); + +export function objectStoragePrefix() { + // + // createEnv normalizes empty strings for defaults, but object storage needs to distinguish + // an explicit empty prefix from an unset prefix because "" means bucket root. + return ( + rawEnvValue('OBJECT_STORAGE_PREFIX') ?? rawEnvValue('R2_PREFIX') ?? env.OBJECT_STORAGE_PREFIX ?? env.R2_PREFIX + ); +} + +function rawEnvValue(key: 'OBJECT_STORAGE_PREFIX' | 'R2_PREFIX') { + // + if (!Object.prototype.hasOwnProperty.call(process.env, key)) return undefined; + + return process.env[key] ?? ''; +} diff --git a/apps/meseeks/schemas/fileSchema.ts b/apps/meseeks/schemas/fileSchema.ts new file mode 100644 index 00000000..ebb84fb1 --- /dev/null +++ b/apps/meseeks/schemas/fileSchema.ts @@ -0,0 +1,54 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import { authorSchema } from './authorSchema'; + +export const fileBudgetSchema = z.object({ + total: z.bigint(), + available: z.bigint(), + reserved: z.bigint(), + spent: z.bigint(), +}); + +export const textContentPointerSchema = z.object({ + kind: z.literal('text'), + content: zid('file_contents'), +}); + +export const objectContentPointerSchema = z.object({ + kind: z.literal('object'), + storageKey: z.string().min(1), + size: z.number().int().nonnegative(), + contentType: z.string().min(1).optional(), +}); + +export const contentPointerSchema = z.union([textContentPointerSchema, objectContentPointerSchema]); + +export const sourceFileSchema = z.object({ + sourceOwner: zid('users').optional(), + sourceKey: z.string().min(1).optional(), + sourceFile: zid('files').optional(), +}); + +export const fileSchema = z + .object({ + owner: zid('users'), + parent: zid('files').optional(), + name: z.string().min(1), + author: authorSchema, + provider: z.string().min(1).optional(), + providerReference: z.string().min(1).optional(), + currentContent: contentPointerSchema.optional(), + budget: fileBudgetSchema.optional(), + isPublic: z.boolean().optional(), + createdAt: z.number(), + updatedAt: z.number(), + }) + .merge(sourceFileSchema); + +export const fileContentSchema = z.object({ + owner: zid('users'), + file: zid('files'), + author: authorSchema, + text: z.string(), + createdAt: z.number(), +}); diff --git a/apps/meseeks/schemas/fileTagSchema.ts b/apps/meseeks/schemas/fileTagSchema.ts new file mode 100644 index 00000000..64395d42 --- /dev/null +++ b/apps/meseeks/schemas/fileTagSchema.ts @@ -0,0 +1,12 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import { authorSchema } from './authorSchema'; + +export const fileTagSchema = z.object({ + owner: zid('users'), + file: zid('files'), + key: z.string().min(1), + value: z.string(), + author: authorSchema, + createdAt: z.number(), +}); diff --git a/apps/meseeks/schemas/intelligenceSchema.test.ts b/apps/meseeks/schemas/intelligenceSchema.test.ts new file mode 100644 index 00000000..f6f87af1 --- /dev/null +++ b/apps/meseeks/schemas/intelligenceSchema.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from 'bun:test'; +import { referenceConcreteIntelligence, referenceIntelligenceSelection } from './intelligenceSchema'; + +describe('intelligence references', () => { + test('keeps Genius as the action intelligence key while resolving to its concrete provider model', () => { + const genius = referenceIntelligenceSelection({ + key: 'Genius', + }); + + expect(genius.key).toBe('Genius'); + expect(genius.intelligence).toBe('openai/gpt-5.5'); + expect(genius.provider).toEqual({ + provider: 'openai', + intelligence: 'gpt-5.5', + }); + }); + + test('exposes GPT 5.5 Pro as a concrete OpenAI intelligence', () => { + const intelligence = referenceConcreteIntelligence({ + key: 'openai/gpt-5.5-pro', + }); + + expect(intelligence.name).toBe('GPT 5.5 Pro'); + expect(intelligence.provider).toEqual({ + provider: 'openai', + intelligence: 'gpt-5.5-pro', + }); + }); +}); diff --git a/apps/meseeks/schemas/intelligenceSchema.ts b/apps/meseeks/schemas/intelligenceSchema.ts index ab463e25..dc27cf1f 100644 --- a/apps/meseeks/schemas/intelligenceSchema.ts +++ b/apps/meseeks/schemas/intelligenceSchema.ts @@ -1,13 +1,10 @@ import { z } from 'zod/v3'; import { asBigInt } from 'lib/money'; -// TODO: dynamic read from models.dev - -// TODO: move those into env vars -const TOKEN_TO_WORD_RATIO = 0.75; // 1 token ≈ 0.75 words -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 +const TOKEN_TO_WORD_RATIO = 0.75; +const WORD_TO_TOKEN_RATIO = 1 / TOKEN_TO_WORD_RATIO; +const INPUT_COST_WEIGHT = 80n; +const OUTPUT_COST_WEIGHT = 20n; const buildContext = (maxTokens: number) => ({ maxTokens, @@ -18,19 +15,14 @@ const buildPricing = ({ input, output }: { input: number; output: number }) => { // const inputPerToken = asBigInt({ dollars: input }) / 1_000_000n; const outputPerToken = asBigInt({ dollars: output }) / 1_000_000n; - // - // pricing per million tokens const inputPerMillionToken = inputPerToken * 1_000_000n; const outputPerMillionToken = outputPerToken * 1_000_000n; - // const weightedInputCost = (inputPerMillionToken * INPUT_COST_WEIGHT) / 100n; const weightedOutputCost = (outputPerMillionToken * OUTPUT_COST_WEIGHT) / 100n; const estimatedCostPerMillion = weightedInputCost + weightedOutputCost; - // - // estimated cost per million words const tokensPerMillionWords = BigInt(Math.round(WORD_TO_TOKEN_RATIO * 1_000_000)); const estimatedPerMillionWords = (estimatedCostPerMillion * tokensPerMillionWords) / 1_000_000n; - // + return { inputPerToken, inputPerMillionToken, @@ -40,518 +32,255 @@ const buildPricing = ({ input, output }: { input: number; output: number }) => { }; }; -export const DEFAULT_INTELLIGENCE: IntelligenceKey = 'deepseek/deepseek-v4-flash'; +export const concreteIntelligenceKeys = z.enum([ + 'deepseek/deepseek-v4-flash', + 'deepseek/deepseek-v4-pro', + 'moonshot/kimi-k2.5', + 'moonshot/kimi-k2.6', + 'openai/gpt-5.4-mini', + 'openai/gpt-5.5', + 'openai/gpt-5.5-pro', +]); -// dynamically chooses the intelligence to use based on the available energy -export const INTELLIGENCE_PROGRESSION = { - 'deepseek/deepseek-v4-flash': 0.2, - 'moonshot/kimi-2.5': 50.0, - 'anthropic/claude-4.5-opus': Number.POSITIVE_INFINITY, -} as const; +export type ConcreteIntelligenceKey = z.infer; 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', + 'Cheap', + 'Efficient', + 'Genius', + 'deepseek/deepseek-v4-flash', + 'deepseek/deepseek-v4-pro', + 'moonshot/kimi-k2.5', + 'moonshot/kimi-k2.6', + 'openai/gpt-5.4-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', + 'openai/gpt-5.5-pro', +]); - // 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', +export type IntelligenceKey = z.infer; - // Groq - 'groq/qwen3-32b', +export const DEFAULT_INTELLIGENCE: IntelligenceKey = 'Cheap'; +export const RECOMMENDED_INTELLIGENCE_KEYS: IntelligenceKey[] = ['Cheap', 'Efficient', 'Genius']; - // DeepSeek - 'deepseek/deepseek-v4-pro', - 'deepseek/deepseek-v4-flash', - 'deepseek/deepseek-v3', +export const intelligenceProviderSchema = z.object({ + provider: z.enum(['deepseek', 'moonshot', 'openai']), + intelligence: z.string().min(1), +}); - // Moonshot - 'moonshot/kimi-2', - 'moonshot/kimi-2.5', +const intelligencePricingSchema = z.object({ + inputPerToken: z.bigint(), + inputPerMillionToken: z.bigint(), + outputPerToken: z.bigint(), + outputPerMillionToken: z.bigint(), + estimatedPerMillionWords: z.bigint(), +}); - // Inception Labs - 'inception/mercury-2', +const intelligenceContextSchema = z.object({ + maxTokens: z.number(), + maxWords: z.number(), +}); - // Cerebras - 'cerebras/qwen3-235b', - 'cerebras/zai-glm-4.7', - 'cerebras/zai-glm-4.6', +export const intelligenceSchema = z.object({ + key: intelligenceKeys, + name: z.string().min(1), + description: z.string().optional(), + target: concreteIntelligenceKeys.optional(), + providerName: z.string().min(1).optional(), + provider: intelligenceProviderSchema.optional(), + pricing: intelligencePricingSchema.optional(), + context: intelligenceContextSchema.optional(), + intelligenceLevel: z.number().optional(), + budgetCeiling: z.bigint().nullable().optional(), + deprecatedAt: z.number().optional(), + deactivatedAt: z.number().optional(), +}); - // DeepInfra - 'deepinfra/qwen-3-coder', - 'deepinfra/glm-4.5', +export type Intelligence = z.infer; - // OpenRouter - 'openrouter/qwen-3-coder', - 'openrouter/GLM-4.5-Air', - 'openrouter/GLM-4.5', -]); +const concreteIntelligenceSchema = intelligenceSchema.extend({ + key: concreteIntelligenceKeys, + providerName: z.string().min(1), + provider: intelligenceProviderSchema, + pricing: intelligencePricingSchema, + context: intelligenceContextSchema, + intelligenceLevel: z.number(), + target: z.undefined().optional(), +}); -export const INTELLIGENCES: Record = { - // +export type ConcreteIntelligence = z.infer; - // ============================== - // 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), +export const INTELLIGENCES = { + 'Cheap': intelligenceSchema.parse({ + key: 'Cheap', + name: 'Cheap', + description: 'Small, fast, inexpensive intelligence for light work.', + target: 'deepseek/deepseek-v4-flash', + budgetCeiling: asBigInt({ dollars: 1 }), + }), + 'Efficient': intelligenceSchema.parse({ + key: 'Efficient', + name: 'Efficient', + description: 'Default workhorse intelligence for most PRO work.', + target: 'moonshot/kimi-k2.5', + budgetCeiling: asBigInt({ dollars: 5 }), + }), + 'Genius': intelligenceSchema.parse({ + key: 'Genius', + name: 'Genius', + description: 'Highest quality intelligence for complex reasoning.', + target: 'openai/gpt-5.5', + budgetCeiling: null, + }), + 'deepseek/deepseek-v4-flash': concreteIntelligenceSchema.parse({ + key: 'deepseek/deepseek-v4-flash', + name: 'DeepSeek V4 Flash', + providerName: 'DeepSeek', + provider: { + provider: 'deepseek', + intelligence: 'deepseek-v4-flash', + }, + pricing: buildPricing({ input: 0.14, output: 0.28 }), + context: buildContext(1_000_000), + intelligenceLevel: 6, + }), + 'deepseek/deepseek-v4-pro': concreteIntelligenceSchema.parse({ + key: 'deepseek/deepseek-v4-pro', + name: 'DeepSeek V4 Pro', + providerName: 'DeepSeek', + provider: { + provider: 'deepseek', + intelligence: 'deepseek-v4-pro', + }, + pricing: buildPricing({ input: 0.435, output: 0.87 }), + context: buildContext(1_000_000), + intelligenceLevel: 8, + }), + 'moonshot/kimi-k2.5': concreteIntelligenceSchema.parse({ + key: 'moonshot/kimi-k2.5', + name: 'Kimi K2.5', + providerName: 'Moonshot', + provider: { + provider: 'moonshot', + intelligence: 'kimi-k2.5', + }, + pricing: buildPricing({ input: 0.6, output: 3 }), + context: buildContext(250_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 }), + }), + 'moonshot/kimi-k2.6': concreteIntelligenceSchema.parse({ + key: 'moonshot/kimi-k2.6', + name: 'Kimi K2.6', + providerName: 'Moonshot', + provider: { + provider: 'moonshot', + intelligence: 'kimi-k2.6', + }, + pricing: buildPricing({ input: 0.6, output: 3 }), + context: buildContext(250_000), + intelligenceLevel: 9, + }), + 'openai/gpt-5.4-mini': concreteIntelligenceSchema.parse({ + key: 'openai/gpt-5.4-mini', + name: 'GPT 5.4 Mini', + providerName: 'OpenAI', + provider: { + provider: 'openai', + intelligence: 'gpt-5.4-mini', + }, + pricing: buildPricing({ input: 0.25, output: 2 }), 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': { + }), + 'openai/gpt-5.5': concreteIntelligenceSchema.parse({ key: 'openai/gpt-5.5', - name: 'GPT-5.5', - description: 'Not recommended. Use GPT-5.4 instead for half the price.', - provider: 'OpenAI', + name: 'GPT 5.5', + providerName: 'OpenAI', + provider: { + provider: 'openai', + intelligence: 'gpt-5.5', + }, 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 }), + }), + 'openai/gpt-5.5-pro': concreteIntelligenceSchema.parse({ + key: 'openai/gpt-5.5-pro', + name: 'GPT 5.5 Pro', + providerName: 'OpenAI', + provider: { + provider: 'openai', + intelligence: 'gpt-5.5-pro', + }, + pricing: buildPricing({ input: 10, output: 60 }), 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, - }, + intelligenceLevel: 10, + }), +} satisfies Record; - // ============================== - // 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 }), - 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, - }, +export function referenceIntelligence(args: { key: string }) { + // + const parsed = intelligenceKeys.safeParse(args.key); + if (!parsed.success) throw new Error(`Unknown intelligence ${args.key}`); - // ============================== - // 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, - }, + return INTELLIGENCES[parsed.data]; +} - // ============================== - // 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, - }, +export function referenceConcreteIntelligence(args: { key: string }) { + // + const intelligence = referenceIntelligence(args); + const concreteKey = intelligence.target ?? intelligence.key; + const parsed = concreteIntelligenceKeys.safeParse(concreteKey); + if (!parsed.success) throw new Error(`Intelligence ${args.key} does not resolve to a concrete provider.`); - // ============================== - // DeepSeek - // ============================== - 'deepseek/deepseek-v4-pro': { - key: 'deepseek/deepseek-v4-pro', - name: 'DeepSeek V4 Pro', - provider: 'DeepSeek', - pricing: buildPricing({ input: 0.435, output: 0.87 }), - 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, - }, + return concreteIntelligenceSchema.parse(INTELLIGENCES[parsed.data]); +} - // ============================== - // Moonshot - // ============================== - 'moonshot/kimi-2.5': { - key: 'moonshot/kimi-2.5', - name: 'Kimi 2.5', - description: 'Pareto frontier 🎯', - 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', - provider: 'Moonshot', - pricing: buildPricing({ input: 0.6, output: 2.5 }), - context: buildContext(128_000), - intelligenceLevel: 7, - }, +export function referenceIntelligenceSelection(args: { key: string }) { + // + const intelligence = referenceIntelligence(args); + const concrete = referenceConcreteIntelligence({ key: intelligence.key }); - // ============================== - // 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, - }, + return { + key: intelligence.key, + label: intelligence.name, + intelligence: concrete.key, + provider: concrete.provider, + budgetCeiling: intelligence.budgetCeiling ?? null, + deprecatedAt: intelligence.deprecatedAt ?? concrete.deprecatedAt, + deactivatedAt: intelligence.deactivatedAt ?? concrete.deactivatedAt, + }; +} - // ============================== - // Cerebras - // ============================== - 'cerebras/qwen3-235b': { - key: 'cerebras/qwen3-235b', - name: 'Qwen 235B', - provider: 'Cerebras', - pricing: buildPricing({ input: 0, output: 0 }), - context: buildContext(128_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, - }, +export function displayIntelligence(args: { key: IntelligenceKey }) { + // + const intelligence = INTELLIGENCES[args.key]; + const concrete = referenceConcreteIntelligence({ key: args.key }); - // ============================== - // 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, - }, + return { + ...intelligence, + providerName: concrete.providerName, + provider: concrete.provider, + pricing: concrete.pricing, + context: concrete.context, + intelligenceLevel: concrete.intelligenceLevel, + deprecatedAt: intelligence.deprecatedAt ?? concrete.deprecatedAt, + deactivatedAt: intelligence.deactivatedAt ?? concrete.deactivatedAt, + }; +} - // ============================== - // OpenRouter - // ============================== - 'openrouter/qwen-3-coder': { - key: 'openrouter/qwen-3-coder', - name: 'Qwen 3 Coder', - provider: 'OpenRouter', - pricing: buildPricing({ input: 0.6, output: 2.5 }), - 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 type DisplayIntelligence = ReturnType; -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 - pricing: z.object({ - inputPerToken: z.bigint(), - inputPerMillionToken: z.bigint(), - outputPerToken: z.bigint(), - outputPerMillionToken: z.bigint(), - estimatedPerMillionWords: z.bigint(), - }), - context: z.object({ - maxTokens: z.number(), - maxWords: z.number(), - }), - intelligenceLevel: z.number(), - // TODO: add speed score - // TODO: add deprecated and deactivated flags - // TODO: see models.dev for other details to add -}); +export function estimateIntelligenceCost(args: { intelligence: string; inputTokens: number; outputTokens: number }) { + // + const concrete = referenceConcreteIntelligence({ key: args.intelligence }); + const input = (concrete.pricing.inputPerMillionToken * BigInt(args.inputTokens)) / 1_000_000n; + const output = (concrete.pricing.outputPerMillionToken * BigInt(args.outputTokens)) / 1_000_000n; + const amount = input + output; + if (amount <= 0n) return undefined; -export type IntelligenceKey = z.infer; -export type Intelligence = z.infer; + return { + symbol: 'USD', + amount, + description: `${args.intelligence} usage`, + }; +} diff --git a/apps/meseeks/schemas/loopSchema.ts b/apps/meseeks/schemas/loopSchema.ts new file mode 100644 index 00000000..3e22318d --- /dev/null +++ b/apps/meseeks/schemas/loopSchema.ts @@ -0,0 +1,24 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import { authorSchema } from './authorSchema'; + +export const loopVisualSchema = z.object({ + icon: z.string().min(1), + color: z.string().min(1), + tint: z.string().min(1), +}); + +export const loopSchema = z.object({ + owner: zid('users'), + key: z.string().min(1), + name: z.string().min(1), + description: z.string().default(''), + defaultIntelligenceKey: z.string().min(1).optional(), + visual: loopVisualSchema, + isPublic: z.boolean().optional(), + sourceOwner: zid('users').optional(), + sourceKey: z.string().min(1).optional(), + author: authorSchema, + createdAt: z.number(), + updatedAt: z.number(), +}); diff --git a/apps/meseeks/schemas/polarEventSchema.ts b/apps/meseeks/schemas/polarEventSchema.ts index db1c0397..cecf2594 100644 --- a/apps/meseeks/schemas/polarEventSchema.ts +++ b/apps/meseeks/schemas/polarEventSchema.ts @@ -3,7 +3,7 @@ import { z } from 'zod/v3'; export const polarEventSchema = z.object({ type: z.string(), timestamp: z.string().optional(), - data: z.record(z.any()), + data: z.record(z.unknown()), }); export const orderDataSchema = z.object({ diff --git a/apps/meseeks/schemas/readSchema.ts b/apps/meseeks/schemas/readSchema.ts new file mode 100644 index 00000000..212af8f0 --- /dev/null +++ b/apps/meseeks/schemas/readSchema.ts @@ -0,0 +1,9 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; + +export const readSchema = z.object({ + user: zid('users'), + file: zid('files'), + lastReadActionIndex: z.number().int().nonnegative(), + lastReadAt: z.number(), +}); diff --git a/apps/meseeks/schemas/routeSchema.ts b/apps/meseeks/schemas/routeSchema.ts new file mode 100644 index 00000000..fccac933 --- /dev/null +++ b/apps/meseeks/schemas/routeSchema.ts @@ -0,0 +1,17 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import { authorSchema } from './authorSchema'; + +export const routeSchema = z.object({ + owner: zid('users'), + slug: z.string().min(1), + file: zid('files'), + defaultFile: zid('files').optional(), + isPublic: z.boolean().optional(), + sourceOwner: zid('users').optional(), + sourceKey: z.string().min(1).optional(), + sourceFile: zid('files').optional(), + author: authorSchema, + createdAt: z.number(), + updatedAt: z.number(), +}); 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..0ba29c2b 100644 --- a/apps/meseeks/schemas/skillSchema.tsx +++ b/apps/meseeks/schemas/skillSchema.tsx @@ -1,309 +1,86 @@ import { zid } from 'convex-helpers/server/zod3'; import { z } from 'zod/v3'; -import { asBigInt } from 'lib/money'; import { authorSchema } from './authorSchema'; import { intelligenceKeys } from './intelligenceSchema'; -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 -]); - -export const skillAuthorSchema = z.union([ - authorSchema, // user or meseeks-defined skills - z.literal('built-in'), // global skills -]); - -export const skillKindSchema = z.enum([ - 'built-in', // - 'hard', - 'soft', -]); - -// TODO: idea: the initial seed is just an action that happens on the onboarding task +export const skillKindSchema = z.enum(['instinct', 'think', 'request', 'execute']); -export const httpConfigSchema = z.object({ - url: z.string().url(), - method: z.enum([ - 'GET', // - 'POST', - 'PUT', - 'DELETE', - 'PATCH', - ]), - headers: z.record(z.string()).describe('HTTP headers to send with the request'), - 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'), - }), - ), - body: z - .object({ - template: z.record(z.any()).describe('Base JSON object with pre-filled values').optional(), - }) - .optional(), -}); +export const storedSkillKindSchema = skillKindSchema.exclude(['instinct']); -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'), +export const skillInputArgumentTypeSchema = z.enum([ + 'string', // + 'number', + 'integer', + 'boolean', + 'bigint', + 'file', + 'json', ]); -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'), - 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.', - ), +export const skillInputArgumentSchema = z.object({ + key: z.string().min(1), + type: skillInputArgumentTypeSchema, + required: z.boolean(), + description: z.string().default(''), }); -// Export individual schemas for reuse in forms and other contexts -export const preApprovedCostSchema = z.union([ - z.literal('none'), - 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.', - ), -]); - -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 type SkillInputArgument = z.input; const coreSkillSchema = z.object({ - key: z.string().min(3).describe('The key of the skill. Must be unique.'), - description: z.string(), - inputSchema: z.string(), // TODO: enforce that this is a valid zod schema - // outputSchema?: z.string(), // not yet - 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.'), -}); - -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 hardSkillSchema = coreSkillSchema.extend({ - kind: z.literal('hard'), - cost: z - .bigint() // - .min(asBigInt({ dollars: 0 })) - .max(asBigInt({ dollars: 1000 })) - .describe('The cost to use this skill, in energy.'), - config: httpConfigSchema, + owner: zid('users'), + key: z.string().min(1), + name: z.string().min(1), + description: z.string().default(''), + input: z.array(skillInputArgumentSchema).optional(), + isPublic: z.boolean().optional(), + sourceOwner: zid('users').optional(), + sourceKey: z.string().min(1).optional(), + sourceFile: zid('files').optional(), + author: authorSchema, + createdAt: z.number(), + updatedAt: z.number(), }); -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 skillSchema = z - .union([ - builtInSkillSchema, // - hardSkillSchema, - softSkillSchema, - ]) - .describe( - 'A Skill is an external API call (service or LLM) that can be used by the user or Meseeks.', // - ); - -export const newSkillSchema = z.union([ - hardSkillSchema.omit({ author: true, owner: true }), // - softSkillSchema.omit({ author: true, owner: 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 +export const thinkSkillSchema = coreSkillSchema.extend({ + kind: z.literal('think'), + file: zid('files'), + intelligence: z.union([z.literal('auto'), intelligenceKeys]).default('auto'), + temperature: z.number().min(0).max(2).optional(), + toolPolicy: z .object({ - template: z.record(z.any()).describe('Base JSON object with pre-filled values').optional(), + skillKeys: z.array(z.string().min(1)).default([]), + includeFileSkills: z.boolean().default(true), }) .optional(), }); -export const simplifiedHardSkillSchema = simplifiedCoreSkillSchema.extend({ - kind: z.literal('hard'), - config: simplifiedHttpConfigSchema, +export const requestHeaderSchema = z.union([ + z.object({ + kind: z.literal('literal'), + name: z.string().min(1), + value: z.string(), + }), + z.object({ + kind: z.literal('env'), + name: z.string().min(1), + env: z.string().min(1), + }), +]); + +export const requestSkillSchema = coreSkillSchema.extend({ + kind: z.literal('request'), + file: zid('files'), + method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']), + url: z.string().min(1), + headers: z.array(requestHeaderSchema).default([]), }); -export const simplifiedSoftSkillSchema = simplifiedCoreSkillSchema.extend({ - kind: z.literal('soft'), - config: simplifiedDecisionConfigSchema, +export const executeSkillSchema = coreSkillSchema.extend({ + kind: z.literal('execute'), + file: zid('files'), + command: z.string().min(1), + timeoutMs: z.number().int().positive().optional(), + env: z.record(z.string()).optional(), }); -// Simplified skill schema for the learn() skill TODO: organize better -export const simplifiedSkillSchema = z.union([simplifiedHardSkillSchema, simplifiedSoftSkillSchema]); +export const skillSchema = z.union([thinkSkillSchema, requestSkillSchema, executeSkillSchema]); 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 deleted file mode 100644 index 06552389..00000000 --- a/apps/meseeks/schemas/toolSchema.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { Tool, ToolSet } from 'ai'; -import type { z } from 'zod/v3'; -import type { newActionSchema } from './actionSchema'; -import type { tokenSchema } from './topUpSchema'; - -// standardized tool result type for all Meseeks tools -export type AIToolResult = { - result: { - text?: string | undefined; - reactions: Array>; - }; - costs: Array<{ - symbol: z.infer; - amount: bigint; - description: string; - }>; -}; - -// AITool uses AI SDK's Tool type with our result type -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type AITool = Tool; - -// Re-export ToolSet for convenience -export type { ToolSet }; diff --git a/apps/meseeks/schemas/transactionSchema.tsx b/apps/meseeks/schemas/transactionSchema.tsx index e9ce5111..65784570 100644 --- a/apps/meseeks/schemas/transactionSchema.tsx +++ b/apps/meseeks/schemas/transactionSchema.tsx @@ -7,53 +7,45 @@ export const valueSchema = z.object({ amount: z.bigint(), }); -export const freeCreditsTransactionSchema = z.object({ - kind: z.literal('free credits'), - value: valueSchema, +const transactionBaseSchema = z.object({ owner: zid('users'), - description: z.string().optional(), + value: valueSchema, + description: z.string().min(1), + createdAt: z.number(), }); -export const topUpTransactionSchema = z.object({ - kind: z.literal('top up'), - value: valueSchema, - topUpId: zid('topUps'), - owner: zid('users'), - description: z.string().optional(), +export const freeCreditsTransactionSchema = transactionBaseSchema.extend({ + kind: z.literal('free credits'), }); -export const taskCostTransactionSchema = z.object({ - kind: z.literal('fund task'), - value: valueSchema, - taskId: zid('tasks'), - owner: zid('users'), - description: z.string().optional(), +export const topUpTransactionSchema = transactionBaseSchema.extend({ + kind: z.literal('top up'), + topUpId: zid('topUps'), }); -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 actionSettlementTransactionSchema = transactionBaseSchema.extend({ + kind: z.literal('action settlement'), + file: zid('files'), + action: zid('actions'), }); -export const subscriptionTransactionSchema = z.object({ +export const subscriptionTransactionSchema = transactionBaseSchema.extend({ kind: z.literal('subscription'), - value: valueSchema, subscriptionId: zid('subscriptions'), - owner: zid('users'), - description: z.string().optional(), }); +export const newTransactionSchema = z.union([ + freeCreditsTransactionSchema.omit({ createdAt: true }), + topUpTransactionSchema.omit({ createdAt: true }), + actionSettlementTransactionSchema.omit({ createdAt: true }), + subscriptionTransactionSchema.omit({ createdAt: true }), +]); + export const transactionSchema = z .union([ freeCreditsTransactionSchema, // topUpTransactionSchema, - taskCostTransactionSchema, - refundTaskTransactionSchema, + actionSettlementTransactionSchema, subscriptionTransactionSchema, ]) - .describe( - 'A financial transaction. Top ups, pay outs and task/action execution costs.', // - ); + .describe('The account wallet ledger. Settlement creates transactions; reservations do not charge the wallet.'); diff --git a/apps/meseeks/schemas/triggerSchema.ts b/apps/meseeks/schemas/triggerSchema.ts new file mode 100644 index 00000000..40d643c0 --- /dev/null +++ b/apps/meseeks/schemas/triggerSchema.ts @@ -0,0 +1,25 @@ +import { zid } from 'convex-helpers/server/zod3'; +import { z } from 'zod/v3'; +import { authorSchema } from './authorSchema'; + +const coreTriggerSchema = z.object({ + owner: zid('users'), + handler: zid('files'), + maxUses: z.number().int().nonnegative(), + uses: z.number().int().nonnegative(), + author: authorSchema, + createdAt: z.number(), + updatedAt: z.number(), +}); + +export const fileTriggerSchema = coreTriggerSchema.extend({ + kind: z.literal('file'), + file: zid('files'), +}); + +export const loopTriggerSchema = coreTriggerSchema.extend({ + kind: z.literal('loop'), + loop: zid('loops'), +}); + +export const triggerSchema = z.union([fileTriggerSchema, loopTriggerSchema]); diff --git a/apps/meseeks/schemas/userSchema.tsx b/apps/meseeks/schemas/userSchema.tsx index a4a37f72..81fe601a 100644 --- a/apps/meseeks/schemas/userSchema.tsx +++ b/apps/meseeks/schemas/userSchema.tsx @@ -15,14 +15,16 @@ export const userSchema = z.object({ walletChain: z.enum(['worldchain']).optional(), isReady: z.boolean().default(false), balanceUSD: z.bigint().default(0n), + committedBudgetUSD: z.bigint().default(0n), isFounder: z.boolean().default(false), - initialTaskId: zid('tasks').optional(), + rootFileId: zid('files').optional(), + managedSeedVersion: z.string().optional(), }); export const userPreferencesSchema = z.object({ owner: zid('users'), key: z.string(), - value: z.any(), + value: z.unknown(), }); export const userRequestKeySchema = z.enum([ @@ -36,5 +38,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..037122fe 100644 --- a/apps/meseeks/scripts/preview-env.ts +++ b/apps/meseeks/scripts/preview-env.ts @@ -58,6 +58,10 @@ export function previewRef(previewName: string) { return `preview/${previewName}`; } +export function previewObjectStoragePrefix(previewName: string) { + return `preview/${objectStoragePrefixSegment(previewName)}`; +} + export function loadEnvLocal() { return readDotenv(envLocalFile); } @@ -205,6 +209,15 @@ function normalizePreviewName(value: string) { return value.replace(/^preview\//, '').trim(); } +function objectStoragePrefixSegment(value: string) { + const normalized = value + .trim() + .replace(/[^A-Za-z0-9./_-]+/g, '-') + .replace(/^\/+|\/+$/g, ''); + + return normalized || 'unnamed'; +} + function parseDotenvValue(rawValue: string) { const value = stripDotenvComment(rawValue).trim(); if (!value) return ''; diff --git a/apps/meseeks/scripts/preview.ts b/apps/meseeks/scripts/preview.ts index 94ae7fc1..559252b8 100644 --- a/apps/meseeks/scripts/preview.ts +++ b/apps/meseeks/scripts/preview.ts @@ -7,6 +7,7 @@ import { getPreviewRun, loadEnvLocal, previewCommandEnv, + previewObjectStoragePrefix, previewRef, readDotenv, removePreviewUnsafeEntries, @@ -20,6 +21,7 @@ import { const convexProjectSelector = 'isPro:meseeks'; const repoRoot = '../..'; const previewSeededRefKey = 'CONVEX_PREVIEW_SEEDED_REF'; +const objectStoragePrefixKey = 'OBJECT_STORAGE_PREFIX'; function main() { assertAppRoot(); @@ -30,6 +32,7 @@ function main() { const previewName = getPreviewName(args); const deploymentRef = previewRef(previewName); const createdDeployment = selectPreviewDeployment(previewName, deploymentRef); + setPreviewObjectStoragePrefix(deploymentRef, previewObjectStoragePrefix(previewName)); const entries = writePreviewEnv(previewName, deploymentRef); const env = previewCommandEnv(entries); @@ -88,6 +91,11 @@ function getPreviewExpiration() { return expiration || undefined; } +function setPreviewObjectStoragePrefix(deploymentRef: string, prefix: string) { + console.log(`Setting ${objectStoragePrefixKey}=${prefix} for ${deploymentRef}`); + runConvex(['env', 'set', objectStoragePrefixKey, prefix, '--deployment', deploymentRef]); +} + function markPreviewSeeded(deploymentRef: string) { const entries = loadEnvLocal(); entries.set(previewSeededRefKey, deploymentRef); diff --git a/apps/meseeks/scripts/sync-env-to-preview.ts b/apps/meseeks/scripts/sync-env-to-preview.ts index 92b9514d..9e9913de 100644 --- a/apps/meseeks/scripts/sync-env-to-preview.ts +++ b/apps/meseeks/scripts/sync-env-to-preview.ts @@ -1,28 +1,23 @@ -/** - * Sync Convex env vars from 'development' to 'preview' (default one, used for every new environment). - */ - import { spawnSync } from 'node:child_process'; -import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; - -// This script is intentionally app-local. Convex resolves project config from the -// current directory, so running from the repo root would target the wrong shape. -const appPackageName = '@meseeks/app'; +import { chmodSync, writeFileSync } from 'node:fs'; +import { assertAppRoot, envLocalFile, loadEnvLocal, previewObjectStoragePrefix } from './preview-env'; // Keep the intermediate file predictable so it can be inspected when the sync // gets more complicated, but never commit it: it contains copied secret values. const envFile = '.env.convex.dev'; +const args = process.argv.slice(2); // We are maintaining the project-level defaults used by freshly-created Convex -// preview deployments, not a single already-created preview deployment. +// preview deployments and, when available, the currently selected preview too. const targetType = 'preview'; // Preview deployments mostly inherit development values today, with a small set // of intentional differences. Keep those differences in one object so future // preview-specific values can be added without changing the rewrite flow. -const previewOverrides = { +const defaultPreviewOverrides: Record = { ENV_TYPE: 'preview', -} satisfies Record; + OBJECT_STORAGE_PREFIX: 'preview', +}; function main() { assertAppRoot(); @@ -30,15 +25,14 @@ function main() { // Convex prints env vars in dotenv format already. Capture stdout instead of // shell-redirecting so this script works the same in local shells and CI. console.log(`Reading current Convex environment variables into ${envFile}`); - const devEnv = runConvex(['env', 'list'], 'capture'); - const previewEnv = applyPreviewOverrides(devEnv); + const devEnv = runConvex(['env', 'list', '--deployment', 'dev'], 'capture'); + const defaultPreviewEnv = applyPreviewOverrides(devEnv, defaultPreviewOverrides); // `mode` only applies when the file is created. chmod after writing as well // so repeated runs keep the copied secrets readable only by this user. - writeFileSync(envFile, previewEnv, { encoding: 'utf8', mode: 0o600 }); - chmodSync(envFile, 0o600); + writeEnvFile(defaultPreviewEnv); - console.log(`Applying preview overrides: ${Object.keys(previewOverrides).join(', ')}`); + console.log(`Applying default preview overrides: ${Object.keys(defaultPreviewOverrides).join(', ')}`); console.log(`Setting default ${targetType} Convex environment variables with --force`); // `env default set` updates the template used for future deployments. `--force` @@ -46,22 +40,25 @@ function main() { runConvex(['env', 'default', 'set', '--type', targetType, '--from-file', envFile, '--force']); console.log(`Synced ${envFile} to default ${targetType} Convex environment variables.`); -} -function assertAppRoot() { - // A lightweight guard catches the common mistake: running from the workspace - // root because most other commands are invoked as `bun --cwd apps/meseeks`. - if (!existsSync('package.json') || !existsSync('convex.json')) { - throw new Error('Run this from apps/meseeks.'); + const currentPreview = currentPreviewTarget(); + if (!currentPreview) { + console.log(`No current preview deployment found in ${envLocalFile}; skipped deployment env sync.`); + return; } - const packageJson = JSON.parse(readFileSync('package.json', 'utf8')) as { name?: string }; - - // The package name is the final check that this is the main app, not another - // workspace that happens to have a Convex config later. - if (packageJson.name !== appPackageName) { - throw new Error('Run this from apps/meseeks.'); - } + const deploymentOverrides = { + ...defaultPreviewOverrides, + OBJECT_STORAGE_PREFIX: previewObjectStoragePrefix(currentPreview.previewName), + }; + const deploymentEnv = applyPreviewOverrides(devEnv, deploymentOverrides); + writeEnvFile(deploymentEnv); + + console.log( + `Setting ${currentPreview.deploymentRef} Convex environment variables with ${deploymentOverrides.OBJECT_STORAGE_PREFIX}`, + ); + runConvex(['env', 'set', '--deployment', currentPreview.deploymentRef, '--from-file', envFile, '--force']); + console.log(`Synced ${envFile} to ${currentPreview.deploymentRef}.`); } function runConvex(args: string[], mode: 'capture' | 'inherit' = 'inherit') { @@ -84,7 +81,7 @@ function runConvex(args: string[], mode: 'capture' | 'inherit' = 'inherit') { return typeof result.stdout === 'string' ? result.stdout : ''; } -function applyPreviewOverrides(contents: string) { +function applyPreviewOverrides(contents: string, overrides: Record) { // Convex's `env list` output is dotenv-like text, not JSON. Preserve unknown // lines so comments/formatting from future CLI output are not accidentally // discarded while we surgically replace only keys we own. @@ -95,7 +92,7 @@ function applyPreviewOverrides(contents: string) { for (const line of lines) { const key = parseEnvKey(line); - const override = key ? getPreviewOverride(key) : undefined; + const override = key ? getOverride(overrides, key) : undefined; if (key && override !== undefined) { // If the source file ever has duplicate keys, keep the first overridden @@ -113,7 +110,7 @@ function applyPreviewOverrides(contents: string) { // Add preview-only defaults even when the development deployment does not have // the key yet. This is what lets ENV_TYPE diverge cleanly for previews. - for (const [key, value] of Object.entries(previewOverrides)) { + for (const [key, value] of Object.entries(overrides)) { if (!seenOverrides.has(key)) { nextLines.push(`${key}=${formatDotenvValue(value)}`); } @@ -131,16 +128,54 @@ function parseEnvKey(line: string) { return match?.[1]; } -function getPreviewOverride(key: string) { +function getOverride(overrides: Record, key: string) { // Avoid indexing inherited properties. Env names are external text, so keep // the lookup explicit even though the current override object is tiny. - if (Object.prototype.hasOwnProperty.call(previewOverrides, key)) { - return previewOverrides[key as keyof typeof previewOverrides]; + if (Object.prototype.hasOwnProperty.call(overrides, key)) { + return overrides[key]; } return undefined; } +function writeEnvFile(contents: string) { + writeFileSync(envFile, contents, { encoding: 'utf8', mode: 0o600 }); + chmodSync(envFile, 0o600); +} + +function currentPreviewTarget() { + const explicitDeployment = readArg('--deployment'); + const explicitPreviewName = readArg('--preview-name') ?? readArg('--branch'); + if (explicitDeployment) { + return { + deploymentRef: explicitDeployment, + previewName: explicitPreviewName ?? explicitDeployment.replace(/^preview\//, ''), + }; + } + + const entries = loadEnvLocal(); + const deploymentRef = entries.get('CONVEX_PREVIEW_REF'); + const previewName = + explicitPreviewName ?? entries.get('CONVEX_PREVIEW_NAME') ?? deploymentRef?.replace(/^preview\//, ''); + if (!deploymentRef || !previewName) return undefined; + + return { + deploymentRef, + previewName, + }; +} + +function readArg(name: string) { + const prefix = `${name}=`; + const inline = args.find((arg) => arg.startsWith(prefix)); + if (inline) return inline.slice(prefix.length); + + const index = args.indexOf(name); + if (index >= 0) return args[index + 1]; + + return undefined; +} + function formatDotenvValue(value: string) { // Keep simple values readable. Quote only when dotenv parsing could be // ambiguous because of spaces, quotes, or shell-significant characters. 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..0ebb8a15 100644 --- a/apps/meseeks/src/client.tsx +++ b/apps/meseeks/src/client.tsx @@ -1,17 +1,17 @@ -import * as Sentry from '@sentry/react'; +import { browserTracingIntegration, init, replayIntegration } from '@sentry/react'; import { StartClient } from '@tanstack/react-start/client'; import { StrictMode, startTransition } from 'react'; import { hydrateRoot } from 'react-dom/client'; import './lib/bigint-serialization'; import { suppressViewTransitionErrors } from './lib/view-transitions'; -Sentry.init({ +init({ dsn: 'https://0c170522df4ac8c15f74d66ff6619146@o4508540750331904.ingest.us.sentry.io/4508540754591744', enabled: import.meta.env.PROD, environment: import.meta.env.MODE, integrations: [ - Sentry.browserTracingIntegration(), // - Sentry.replayIntegration(), + browserTracingIntegration(), // + replayIntegration(), ], tracesSampleRate: 1.0, // Capture 100% of the transactions // Set 'tracePropagationTargets' to control for which URLs distributed tracing should be enabled diff --git a/apps/meseeks/src/components/Action.tsx b/apps/meseeks/src/components/Action.tsx index 76ccfb8b..c3dbf804 100644 --- a/apps/meseeks/src/components/Action.tsx +++ b/apps/meseeks/src/components/Action.tsx @@ -1,7 +1,6 @@ import { Doc, Id } from 'convex/_generated/dataModel'; -import index from './actions'; -import { GenericAction } from './actions/GenericAction'; +import { componentForActionSkill } from './actions'; export function Action(props: { className?: string; @@ -9,16 +8,10 @@ export function Action(props: { initialRenderDate: Date; isAuthorCurrentUser: boolean; suppressAnchorId?: boolean; - taskId: Id<'tasks'>; + fileId: Id<'files'>; }) { // - if (props.action.skillKey in index) { - // - const Component = index[props.action.skillKey as keyof typeof index]; - if (Component === null) return null; + const Component = componentForActionSkill({ skillKey: props.action.skillKey }); - return ; - } - - return ; + return ; } diff --git a/apps/meseeks/src/components/ActionComposer/ActionComposer.tsx b/apps/meseeks/src/components/ActionComposer/ActionComposer.tsx index 4a9b58d7..d018c662 100644 --- a/apps/meseeks/src/components/ActionComposer/ActionComposer.tsx +++ b/apps/meseeks/src/components/ActionComposer/ActionComposer.tsx @@ -1,25 +1,41 @@ +import { convexQuery } from '@convex-dev/react-query'; +import { useSuspenseQuery } from '@tanstack/react-query'; import type { Doc } from 'convex/_generated/dataModel'; -import { useEffect, useMemo, useRef } from 'react'; +import { api } from 'convex/_generated/api'; +import { ChevronDown, CircleOff, MessageCircle, Radar } from 'lucide-react'; +import { useEffect, useState } from 'react'; import { TooltipProvider } from '@reactor/ui/tooltip'; +import { Button } from '@reactor/ui/button'; 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 { useStop } from '~/hooks/useFileMutations'; import { useVoiceRecording } from '~/hooks/useVoiceRecording'; import { cn } from '@reactor/ui/lib/utils'; +import type { FileView } from '~/hooks/query/useFile'; +import { IntelligencePicker, intelligencePickerOptionsFromData } from '~/components/IntelligencePicker'; import { IdleState } from './IdleState'; import { RecordingState } from './RecordingState'; import { TranscribingState } from './TranscribingState'; import { StripContainer } from './strips/StripContainer'; +const noLoopOption = { + name: 'No loop', + visual: { + icon: 'circle-off', + color: 'zinc', + tint: 'zinc', + }, +}; + interface ActionComposerProps { // - task: Doc<'tasks'>; + file: FileView; onSubmit?: (message: string) => void; className?: string; } -export function ActionComposer({ task, onSubmit, className }: ActionComposerProps) { +export function ActionComposer({ file, onSubmit, className }: ActionComposerProps) { // const { queue, // @@ -30,6 +46,20 @@ export function ActionComposer({ task, onSubmit, className }: ActionComposerProp } = useComposer(); const { stop, isStopping } = useStop(); + const loopsQuery = convexQuery(api.loops.findAll, {}); + const intelligencesQuery = convexQuery(api.loops.intelligenceOptions, {}); + const { data: loopsData } = useSuspenseQuery(loopsQuery); + const { data: intelligences } = useSuspenseQuery(intelligencesQuery); + const intelligencePickerOptions = intelligencePickerOptionsFromData(intelligences); + const seekLoop = loopsData.loops.find((loop) => loop.key === '@pro/Seek'); + const initialLoop = seekLoop ?? loopsData.loops[0]; + const [selectedLoop, setSelectedLoop] = useState(initialLoop?.key ?? null); + const [isLoopPanelOpen, setIsLoopPanelOpen] = useState(false); + const [selectedIntelligence, setSelectedIntelligence] = useState(defaultIntelligenceForLoop(initialLoop)); + + const selectedLoopRecord = loopsData.loops.find((loop) => loop.key === selectedLoop); + const selectedVisual = selectedLoopRecord?.visual ?? noLoopOption.visual; + const selectedLoopName = selectedLoopRecord?.name ?? noLoopOption.name; const { textareaRef, @@ -39,8 +69,6 @@ export function ActionComposer({ task, onSubmit, className }: ActionComposerProp 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) { @@ -70,16 +98,16 @@ export function ActionComposer({ task, onSubmit, className }: ActionComposerProp // 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 isBlocked = file.status === 'blocked' && isLocalEmpty; + const isFileActing = file.status === 'acting' && isLocalEmpty; + const canRequestIteration = isLocalEmpty && !isBlocked && !isFileActing && queue.length === 0; const handleAct = async () => { // - await submit(task); + await submit(file, { + loopKey: selectedLoop, + intelligence: selectedIntelligence, + }); if (!isLocalEmpty) { onSubmit?.(localMessage); @@ -109,7 +137,7 @@ export function ActionComposer({ task, onSubmit, className }: ActionComposerProp const handleStop = () => { // - stop({ taskId: task._id }); + stop({ fileId: file._id }); }; // global focus shortcut (⌘+I) @@ -151,25 +179,44 @@ export function ActionComposer({ task, onSubmit, className }: ActionComposerProp combo: { withCtrl: true, key: 'c' }, skipPreventDefault: true, callback: (e) => { - if (task.status === 'acting' && !isStopping) { + if (file.status === 'acting' && !isStopping) { handleStop(); e.preventDefault(); } }, }); - // intelligence selector shortcut (⌘+/) + // intelligence picker shortcut (⌘+/) useKeyboardShortcut({ global: true, combo: { withCommand: true, key: '/' }, - callback: () => intelligenceSelectorRef.current?.click(), + callback: () => setIsLoopPanelOpen((value) => !value), }); + const handleSelectNoLoop = () => { + // + setSelectedLoop(null); + setSelectedIntelligence('Cheap'); + }; + + const handleSelectLoop = (loop: Doc<'loops'>) => { + // + setSelectedLoop(loop.key); + setSelectedIntelligence(defaultIntelligenceForLoop(loop)); + }; + + const handleSelectIntelligence = (key: string) => { + // + setSelectedIntelligence(key); + setIsLoopPanelOpen(false); + }; + return (
- +
)} @@ -191,25 +238,139 @@ export function ActionComposer({ task, onSubmit, className }: ActionComposerProp {/* 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} - /> + <> + {isLoopPanelOpen && ( +
+
+
Loop
+
+ + {loopsData.loops.map((loop) => ( + + ))} +
+
+
+
+ Intelligence +
+ +
+
+ )} + + 0} + startRecording={startRecording} + handleAct={handleAct} + handleEnqueue={handleEnqueueMessage} + isActing={isFileActing} + isBlocked={isBlocked} + isComposing={isComposing} + canRequestIteration={canRequestIteration} + handleStop={handleStop} + leftControl={ + setIsLoopPanelOpen((value) => !value)} + /> + } + /> + )}
); } + +function LoopButton({ + label, + icon, + color, + isOpen, + onClick, +}: { + label: string; + icon: React.ReactNode; + color: string; + isOpen: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function defaultIntelligenceForLoop(loop: Doc<'loops'> | undefined) { + // + return loop?.defaultIntelligenceKey ?? 'Cheap'; +} + +function tintFor(tint: string) { + // + if (tint === 'emerald') return 'border-emerald-500/40 bg-emerald-500/5'; + if (tint === 'sky') return 'border-sky-500/40 bg-sky-500/5'; + if (tint === 'violet') return 'border-violet-500/40 bg-violet-500/5'; + return 'border-zinc-500/30 bg-zinc-500/5'; +} + +function colorFor(color: string) { + // + if (color === 'emerald') { + return '!border-emerald-500/60 !bg-emerald-600 !text-white hover:!bg-emerald-700 dark:!bg-emerald-400 dark:!text-emerald-950 dark:hover:!bg-emerald-300'; + } + if (color === 'sky') { + return '!border-sky-500/60 !bg-sky-600 !text-white hover:!bg-sky-700 dark:!bg-sky-400 dark:!text-sky-950 dark:hover:!bg-sky-300'; + } + if (color === 'violet') { + return '!border-violet-500/60 !bg-violet-600 !text-white hover:!bg-violet-700 dark:!bg-violet-400 dark:!text-violet-950 dark:hover:!bg-violet-300'; + } + return '!border-zinc-500/60 !bg-zinc-900 !text-zinc-50 hover:!bg-zinc-800 dark:!bg-zinc-100 dark:!text-zinc-950 dark:hover:!bg-zinc-200'; +} + +function iconFor(icon: string) { + // + if (icon === 'message-circle') return ; + if (icon === 'telescope' || icon === 'compass' || icon === 'radar') return ; + return ; +} diff --git a/apps/meseeks/src/components/ActionComposer/IdleState.tsx b/apps/meseeks/src/components/ActionComposer/IdleState.tsx index 619284ff..3d986f00 100644 --- a/apps/meseeks/src/components/ActionComposer/IdleState.tsx +++ b/apps/meseeks/src/components/ActionComposer/IdleState.tsx @@ -1,17 +1,11 @@ -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; @@ -24,12 +18,11 @@ interface IdleStateProps { isActing: boolean; isComposing: boolean; canRequestIteration: boolean; - intelligenceSelectorRef: React.RefObject; handleStop: () => void; + leftControl: React.ReactNode; } export function IdleState({ - task, textareaRef, message, handleMessageChange, @@ -42,16 +35,10 @@ export function IdleState({ isActing, isComposing, canRequestIteration, - intelligenceSelectorRef, handleStop, + leftControl, }: IdleStateProps) { // - const { setPreferredIntelligence, isSettingPreferredIntelligence } = useSetPreferredIntelligence(); - const handleIntelligenceChange = (key: z.infer) => { - if (isSettingPreferredIntelligence) return; - setPreferredIntelligence({ taskId: task._id, preferredIntelligence: key }); - }; - return ( <>
@@ -66,18 +53,14 @@ export function IdleState({
- + {leftControl}
{/* Keyboard shortcut indicators */} {isActing && } - {isBlocked && } + {isBlocked && needs input} {!isEmpty && } {isComposing && } {canRequestIteration && } diff --git a/apps/meseeks/src/components/ActionComposer/strips/BudgetStrip.tsx b/apps/meseeks/src/components/ActionComposer/strips/BudgetStrip.tsx index 15616cf5..c0b26e3f 100644 --- a/apps/meseeks/src/components/ActionComposer/strips/BudgetStrip.tsx +++ b/apps/meseeks/src/components/ActionComposer/strips/BudgetStrip.tsx @@ -1,12 +1,12 @@ -import type { Doc } from 'convex/_generated/dataModel'; import { AddCustomBudgetButton } from '~/components/AddBudgetButton'; -import { TaskBudget } from '~/components/TaskBudget'; +import { FileBudget } from '~/components/FileBudget'; +import type { FileView } from '~/hooks/query/useFile'; -export function BudgetStrip({ task }: { task: Doc<'tasks'> }) { +export function BudgetStrip({ file }: { file: FileView }) { // return (
- +
); diff --git a/apps/meseeks/src/components/ActionComposer/strips/QueueStrip.tsx b/apps/meseeks/src/components/ActionComposer/strips/QueueStrip.tsx index 85c72086..a07ad402 100644 --- a/apps/meseeks/src/components/ActionComposer/strips/QueueStrip.tsx +++ b/apps/meseeks/src/components/ActionComposer/strips/QueueStrip.tsx @@ -1,4 +1,5 @@ import { ChevronDown, Loader2, Trash2, X } from 'lucide-react'; +import { asDollars } from 'lib/money'; import { memo, useCallback, useEffect, useRef, useState } from 'react'; import { Button } from '@reactor/ui/button'; import { useComposer } from '~/hooks/useComposer'; @@ -202,23 +203,13 @@ const PendingItem = memo(function PendingItem({ skill }: { skill: EnqueuedSkill function formatSkillLabel(skillKey: string, args: Record, isPending = false): string { // switch (skillKey) { - case 'increaseBudget': { - const dollars = args['dollars'] as number | undefined; - if (dollars) { - return `+⚡${dollars < 1 ? dollars.toFixed(2) : dollars} budget`; - } - return 'Increase budget'; - } - case 'decreaseBudget': { - const dollars = args['dollars'] as number | undefined; - if (dollars) { - return `-⚡${dollars < 1 ? dollars.toFixed(2) : dollars} budget`; - } - return 'Decrease budget'; + case 'updateBudget': { + const energy = formatEnergyChange(skillKey, args); + if (energy) return energy; + return 'Change energy'; } - case 'say': - case 'justSay': { - const message = args['message'] as string | undefined; + case 'say': { + const message = getStringArg(args, 'message'); if (message) { const truncated = message.length > 100 ? message.slice(0, 100) + '...' : message; return isPending ? `Saying: "${truncated}"` : `Say: "${truncated}"`; @@ -229,3 +220,55 @@ function formatSkillLabel(skillKey: string, args: Record, isPen return skillKey; } } + +function formatEnergyChange(skillKey: string, args: Record) { + // + const dollars = getNumberArg(args, 'dollars'); + if (dollars !== undefined) { + const sign = energySign({ skillKey, amount: dollars }); + const value = Math.abs(dollars); + return `${sign}⚡${formatDollarNumber(value)} energy`; + } + + const amount = getBigIntArg(args, 'amount'); + if (amount === undefined) return undefined; + + const sign = energySign({ skillKey, amount }); + const value = amount < 0n ? -amount : amount; + + return `${sign}⚡${asDollars({ bigInt: value })} energy`; +} + +function energySign(args: { skillKey: string; amount: number | bigint }) { + // + if (typeof args.amount === 'bigint') { + return args.amount < 0n ? '-' : '+'; + } + + if (args.amount < 0) return '-'; + return '+'; +} + +function formatDollarNumber(dollars: number) { + // + if (dollars < 1) return dollars.toFixed(2); + return String(dollars); +} + +function getNumberArg(args: Record, key: string) { + // + const value = args[key]; + return typeof value === 'number' ? value : undefined; +} + +function getBigIntArg(args: Record, key: string) { + // + const value = args[key]; + return typeof value === 'bigint' ? value : undefined; +} + +function getStringArg(args: Record, key: string) { + // + const value = args[key]; + return typeof value === 'string' ? value : undefined; +} diff --git a/apps/meseeks/src/components/ActionComposer/strips/StripContainer.tsx b/apps/meseeks/src/components/ActionComposer/strips/StripContainer.tsx index 32350e1c..68d3887f 100644 --- a/apps/meseeks/src/components/ActionComposer/strips/StripContainer.tsx +++ b/apps/meseeks/src/components/ActionComposer/strips/StripContainer.tsx @@ -1,11 +1,11 @@ -import type { Doc } from 'convex/_generated/dataModel'; import { useState } from 'react'; import { useComposer } from '~/hooks/useComposer'; +import type { FileView } from '~/hooks/query/useFile'; import { BudgetStrip } from './BudgetStrip'; import { DraftStrip } from './DraftStrip'; import { QueueStrip } from './QueueStrip'; -export function StripContainer({ task }: { task: Doc<'tasks'> }) { +export function StripContainer({ file }: { file: FileView }) { // const { queue, pendingSkills } = useComposer(); @@ -35,7 +35,7 @@ export function StripContainer({ task }: { task: Doc<'tasks'> }) { {/* budget strip - always visible */} - + {/* queue strip - visible when there are queued or pending skills */} {hasQueuedOrPendingSkills && ( diff --git a/apps/meseeks/src/components/ActionIsland.tsx b/apps/meseeks/src/components/ActionIsland.tsx deleted file mode 100644 index 996bd3da..00000000 --- a/apps/meseeks/src/components/ActionIsland.tsx +++ /dev/null @@ -1,231 +0,0 @@ -import { type Doc, type Id } from 'convex/_generated/dataModel'; -import { usePaginatedQuery } from 'convex/react'; -import { ChevronDown, Clock3, Loader2, ShieldAlert } from 'lucide-react'; -import { useMemo, useState } from 'react'; -import { api } from 'convex/_generated/api'; -import { useApproveAction, useRejectAction } from '~/hooks/useTaskMutations'; -import { cn } from '@reactor/ui/lib/utils'; -import { Button } from '@reactor/ui/button'; -import { LoadingButton } from '@reactor/ui/loading-button'; -import { Popover, PopoverContent, PopoverTrigger } from '@reactor/ui/popover'; - -const INITIAL_ACTION_COUNT = 20; - -export function ActionIsland({ - taskId, // - className, -}: { - taskId: Id<'tasks'>; - className?: string; -}) { - // - const [isOpen, setIsOpen] = useState(false); - const { results } = usePaginatedQuery( - api.action.findAllPaginated, - { taskId }, - { initialNumItems: INITIAL_ACTION_COUNT }, - ); - - const activeActions = useMemo(() => { - return results.filter(isActiveAction).sort((a, b) => { - const priorityDelta = getActionPriority(a.status) - getActionPriority(b.status); - if (priorityDelta !== 0) return priorityDelta; - - return b._creationTime - a._creationTime; - }); - }, [results]); - - if (activeActions.length === 0) return null; - - const summary = buildSummary(activeActions); - const hasApprovalBlocker = activeActions.some((action) => action.status === 'pending authorization'); - - return ( - - - - - - -
-
Active actions
-
{summary}
-
- -
- {activeActions.map((action) => ( - - ))} -
-
-
- ); -} - -function ActiveActionCard({ action, taskId }: { action: Doc<'actions'>; taskId: Id<'tasks'> }) { - // - const { approveAction, isApprovingAction } = useApproveAction(); - const { rejectAction, isRejectingAction } = useRejectAction(); - const preview = buildActionPreview(action); - - return ( -
-
- - -
-
- {action.skillKey}() -
-
{getStatusLabel(action.status)}
- {preview &&
{preview}
} -
-
- - {action.status === 'pending authorization' && ( -
- approveAction({ taskId, actionId: action._id })} - loading={isApprovingAction} - loadingText="Authorizing..." - > - Authorize - - rejectAction({ taskId, actionId: action._id })} - loading={isRejectingAction} - loadingText="Skipping..." - > - Skip - -
- )} -
- ); -} - -function SummaryIcon({ actions }: { actions: Doc<'actions'>[] }) { - // - if (actions.some((action) => action.status === 'pending authorization')) { - return ; - } - - if (actions.some((action) => action.status === 'running')) { - return ; - } - - return ; -} - -function StatusDot({ status }: { status: Doc<'actions'>['status'] }) { - // - return ( - - ); -} - -function isActiveAction(action: Doc<'actions'>) { - // - return action.status === 'pending authorization' || action.status === 'running' || action.status === 'enqueued'; -} - -function getActionPriority(status: Doc<'actions'>['status']) { - // - if (status === 'pending authorization') return 0; - if (status === 'running') return 1; - if (status === 'enqueued') return 2; - return 3; -} - -function buildSummary(actions: Doc<'actions'>[]) { - // - const pendingAuthorizationCount = actions.filter((action) => action.status === 'pending authorization').length; - const runningCount = actions.filter((action) => action.status === 'running').length; - const enqueuedCount = actions.filter((action) => action.status === 'enqueued').length; - - if (pendingAuthorizationCount > 0) { - const approvalsText = - pendingAuthorizationCount === 1 ? '1 approval needed' : `${pendingAuthorizationCount} approvals needed`; - const remainingActiveCount = runningCount + enqueuedCount; - - return remainingActiveCount > 0 ? `${approvalsText}, ${remainingActiveCount} more active` : approvalsText; - } - - if (runningCount > 0) { - if (enqueuedCount > 0) return `${runningCount} running, ${enqueuedCount} queued`; - return runningCount === 1 ? '1 running' : `${runningCount} running`; - } - - return enqueuedCount === 1 ? '1 queued' : `${enqueuedCount} queued`; -} - -function getStatusLabel(status: Doc<'actions'>['status']) { - // - if (status === 'pending authorization') return 'waiting for your authorization'; - if (status === 'running') return 'running now'; - if (status === 'enqueued') return 'queued'; - return status; -} - -function buildActionPreview(action: Doc<'actions'>) { - // - const message = getStringArg(action, 'message'); - if (message) return truncate(message, 72); - - const instructions = getStringArg(action, 'instructions'); - if (instructions) return truncate(instructions, 72); - - const query = getStringArg(action, 'query'); - if (query) return truncate(query, 72); - - const prompt = getStringArg(action, 'prompt'); - if (prompt) return truncate(prompt, 72); - - const url = getStringArg(action, 'url'); - if (url) return truncate(url, 72); - - const note = getStringArg(action, 'note'); - if (note) return truncate(note, 72); - - return null; -} - -function getStringArg(action: Doc<'actions'>, key: string) { - // - const value = action.args[key]; - if (typeof value !== 'string') return null; - - return value.trim() || null; -} - -function truncate(value: string, maxLength: number) { - // - if (value.length <= maxLength) return value; - - return value.slice(0, maxLength - 3) + '...'; -} diff --git a/apps/meseeks/src/components/ActionTest.tsx b/apps/meseeks/src/components/ActionTest.tsx deleted file mode 100644 index c619b1a3..00000000 --- a/apps/meseeks/src/components/ActionTest.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import { Id } from 'convex/_generated/dataModel'; -import { useEffect, useState } from 'react'; -import { Action } from '~/components/Action'; -import { Button } from '@reactor/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@reactor/ui/card'; -import { Textarea } from '@reactor/ui/textarea'; - -import { z } from 'zod/v3'; -import { cn } from '@reactor/ui/lib/utils'; -import { actionSchema } from 'schemas/actionSchema'; - -// Default test action for setUserInfo -const DEFAULT_ACTION = `{ - "_creationTime": 1750954710909.044, - "_id": "nd72mqnsnze8tk21rextqdtawn7jjsjz", - "approvedAt": 1750954711276, - "approvedBy": "auto", - "args": { - "userInfo": "Igor Silva, born 1997-01-22 in São Paulo, Brazil; raised in Santos; moved to Setúbal, Portugal in Nov/2023; Brazilian/Portuguese citizen; engineer, entrepreneur, investor; speaks English (advanced), Portuguese (native), Spanish (some); Twitter @igor9silva; creator of Meseeks" - }, - "author": "nd7ehvp1shq129m44hfrh8tqt17jkmhy", - "costs": [ - { - "amount": "0", - "description": "Built-in skills are free of charge.", - "symbol": "USD" - } - ], - "depth": 4, - "estimatedCost": null, - "owner": "jd7160sjr09g2dkxhenn3fy9wx74pfsk", - "result": { - "reactions": [ - { - "args": {}, - "author": "nd72mqnsnze8tk21rextqdtawn7jjsjz", - "depth": 5, - "owner": "jd7160sjr09g2dkxhenn3fy9wx74pfsk", - "skillKey": "iterate", - "taskId": "kh79gxscr97dj1xkd3fa1e556h7jkdre" - } - ], - "text": "✅ User information updated." - }, - "skillKey": "setUserInfo", - "status": "succeeded", - "taskId": "kh79gxscr97dj1xkd3fa1e556h7jkdre", - "details": null -}`; - -export function ActionTest({ className }: { className?: string }) { - // - const [actionJSON, setActionJSON] = useState(DEFAULT_ACTION); - const [parsedAction, setParsedAction] = useState | null>(null); - const [error, setError] = useState(''); - - // Auto-parse when JSON changes - useEffect(() => { - // - try { - const parsed = JSON.parse(actionJSON); - - // Basic validation - const requiredFields = ['_id', 'skillKey', 'status', 'args']; - const missingFields = requiredFields.filter((field) => !(field in parsed)); - - if (missingFields.length > 0) { - setError(`Missing required fields: ${missingFields.join(', ')}`); - setParsedAction(null); - return; - } - - setParsedAction(parsed as z.infer); - setError(''); - } catch (err) { - setError(`Invalid JSON: ${err instanceof Error ? err.message : 'Unknown error'}`); - setParsedAction(null); - } - }, [actionJSON]); - - const handleReset = () => { - // - setActionJSON(DEFAULT_ACTION); - setParsedAction(null); - setError(''); - }; - - const handleStatusChange = (newStatus: z.infer['status']) => { - // - if (!parsedAction) return; - - const updatedAction = { - ...parsedAction, - status: newStatus, - }; - - // @ts-expect-error - setParsedAction(updatedAction); - setActionJSON(JSON.stringify(updatedAction, null, 2)); - }; - - return ( -
-
-

UI Test - Action Components

-

Test action components by inputting action JSON data

-
- -
- {/* Input Section */} - - - Action JSON Input - - -