@tuturuuu/ai 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/package.json +41 -41
  2. package/src/chat/google/assistant-message-parts.ts +105 -0
  3. package/src/chat/google/chat-tools.ts +7 -0
  4. package/src/chat/google/google-search-usage.ts +62 -0
  5. package/src/chat/google/mira-step-preparation.ts +37 -16
  6. package/src/chat/google/route-credits.ts +6 -7
  7. package/src/chat/google/route-message-preparation.ts +33 -0
  8. package/src/chat/google/route-mira-runtime.ts +56 -32
  9. package/src/chat/google/route.ts +32 -71
  10. package/src/chat/google/stream-finish-persistence.ts +39 -150
  11. package/src/chat/mira-artifact-intent.ts +38 -0
  12. package/src/chat/mira-prompt.ts +29 -0
  13. package/src/chat/mira-render-ui-policy.ts +65 -20
  14. package/src/chat/mira-system-instruction.ts +8 -172
  15. package/src/chat/mira-tool-loop-guard.ts +84 -0
  16. package/src/credits/cap-output-tokens.ts +48 -15
  17. package/src/credits/check-credits.ts +14 -3
  18. package/src/credits/constants.ts +3 -0
  19. package/src/credits/index.ts +1 -0
  20. package/src/generate/route.ts +6 -3
  21. package/src/meetings/audio-contract.ts +8 -0
  22. package/src/meetings/audio-transcripts.ts +25 -0
  23. package/src/meetings/chat-source-footer.ts +1 -0
  24. package/src/meetings/chat.ts +4 -1
  25. package/src/meetings/gemini.ts +72 -20
  26. package/src/meetings/public-chat-search.ts +4 -1
  27. package/src/meetings/transcript.ts +63 -0
  28. package/src/meetings/workspace-tools.ts +3 -1
  29. package/src/studio/metering.ts +5 -1
  30. package/src/tools/definitions/calendar-automation.ts +43 -0
  31. package/src/tools/definitions/calendar.ts +27 -1
  32. package/src/tools/definitions/meta.ts +17 -1
  33. package/src/tools/definitions/tasks.ts +82 -31
  34. package/src/tools/definitions/workspace-user-chat.ts +24 -0
  35. package/src/tools/executors/calendar-automation.ts +207 -0
  36. package/src/tools/executors/calendar.ts +30 -1
  37. package/src/tools/executors/helpers/encryption.ts +12 -9
  38. package/src/tools/executors/search.ts +40 -9
  39. package/src/tools/executors/task-create.ts +232 -0
  40. package/src/tools/executors/task-dashboard.ts +59 -0
  41. package/src/tools/executors/task-read.ts +76 -0
  42. package/src/tools/executors/tasks.ts +50 -205
  43. package/src/tools/executors/theme.ts +48 -0
  44. package/src/tools/google-search-events.ts +4 -0
  45. package/src/tools/mira-sdk-tool-search.ts +73 -0
  46. package/src/tools/mira-task-write-guard.ts +61 -0
  47. package/src/tools/mira-tool-discovery.ts +138 -0
  48. package/src/tools/mira-tool-dispatcher.ts +33 -1
  49. package/src/tools/mira-tool-metadata.ts +29 -1
  50. package/src/tools/mira-tool-names.ts +13 -0
  51. package/src/tools/mira-tool-types.ts +7 -0
  52. package/src/tools/mira-tools.ts +70 -2
  53. package/src/workspace-artifacts.ts +58 -0
@@ -0,0 +1,207 @@
1
+ import {
2
+ applyWorkspaceCalendarSchedule,
3
+ createWorkspaceCalendarEvent,
4
+ deleteWorkspaceCalendarEvent,
5
+ getGoogleCalendarAuthUrl,
6
+ getWorkspaceCalendarSyncStatus,
7
+ listCalendarConnections,
8
+ listWorkspaceSchedulableTasks,
9
+ previewWorkspaceCalendarSchedule,
10
+ syncWorkspaceCalendar,
11
+ updateWorkspaceCalendarEvent,
12
+ type WorkspaceCalendarEventCreatePayload,
13
+ type WorkspaceCalendarEventUpdatePayload,
14
+ } from '@tuturuuu/internal-api/calendar';
15
+ import { withForwardedInternalApiAuth } from '@tuturuuu/internal-api/client';
16
+ import { resolveInternalAppUrl } from '@tuturuuu/utils/app-url';
17
+ import { Effect } from '@tuturuuu/utils/effect';
18
+ import type { MiraToolContext } from '../mira-tool-types';
19
+ import { getWorkspaceContextWorkspaceId } from '../workspace-context';
20
+
21
+ type SchedulePreview = {
22
+ success: boolean;
23
+ preview?: { summary?: unknown; warnings?: string[]; events?: unknown[] };
24
+ tasks?: unknown;
25
+ habits?: unknown;
26
+ lockedEvents?: unknown[];
27
+ };
28
+ const previews = new WeakMap<object, Set<string>>();
29
+
30
+ export async function executeCalendarAutomation(
31
+ name: string,
32
+ args: Record<string, unknown>,
33
+ ctx: MiraToolContext
34
+ ) {
35
+ if (!ctx.requestHeaders)
36
+ return {
37
+ success: false,
38
+ error:
39
+ 'Calendar automation is unavailable in this session. Open Calendar settings to manage connections.',
40
+ };
41
+ const wsId = getWorkspaceContextWorkspaceId(ctx);
42
+ const state = ctx.executionState ?? ctx;
43
+ const baseUrl = resolveInternalAppUrl({
44
+ appName: 'calendar',
45
+ candidates: [
46
+ process.env.CALENDAR_APP_URL,
47
+ process.env.NEXT_PUBLIC_CALENDAR_APP_URL,
48
+ ],
49
+ fallback:
50
+ process.env.NODE_ENV === 'production'
51
+ ? 'https://calendar.tuturuuu.com'
52
+ : 'https://calendar.tuturuuu.localhost',
53
+ });
54
+ const target = new URL(baseUrl);
55
+ const localDevelopment =
56
+ process.env.NODE_ENV !== 'production' &&
57
+ (['localhost', '127.0.0.1', '[::1]'].includes(target.hostname) ||
58
+ target.hostname.endsWith('.localhost'));
59
+ if (target.protocol !== 'https:' && !localDevelopment)
60
+ return {
61
+ success: false,
62
+ error: 'Calendar requires a secure HTTPS connection.',
63
+ };
64
+ const options = withForwardedInternalApiAuth(ctx.requestHeaders, { baseUrl });
65
+ const windowDays = typeof args.windowDays === 'number' ? args.windowDays : 7;
66
+ const previewKey = `${wsId}:${windowDays}:${ctx.timezone ?? ''}`;
67
+ return Effect.runPromise(
68
+ Effect.tryPromise({
69
+ try: async () => {
70
+ switch (name) {
71
+ case 'create_event': {
72
+ const event = await createWorkspaceCalendarEvent(
73
+ wsId,
74
+ {
75
+ title: args.title as string,
76
+ start_at: new Date(args.startAt as string).toISOString(),
77
+ end_at: new Date(args.endAt as string).toISOString(),
78
+ description: args.description as string | null | undefined,
79
+ location: args.location as string | null | undefined,
80
+ color: args.color as string | undefined,
81
+ locked: args.locked as boolean | undefined,
82
+ source:
83
+ args.source as WorkspaceCalendarEventCreatePayload['source'],
84
+ },
85
+ options
86
+ );
87
+ return { success: true, event, workspaceId: wsId };
88
+ }
89
+ case 'update_event': {
90
+ const payload: WorkspaceCalendarEventUpdatePayload = {};
91
+ for (const [input, output] of [
92
+ ['title', 'title'],
93
+ ['startAt', 'start_at'],
94
+ ['endAt', 'end_at'],
95
+ ['description', 'description'],
96
+ ['location', 'location'],
97
+ ['color', 'color'],
98
+ ['locked', 'locked'],
99
+ ['source', 'source'],
100
+ ] as const) {
101
+ if (args[input] !== undefined)
102
+ Object.assign(payload, {
103
+ [output]:
104
+ input === 'startAt' || input === 'endAt'
105
+ ? new Date(args[input] as string).toISOString()
106
+ : args[input],
107
+ });
108
+ }
109
+ const event = await updateWorkspaceCalendarEvent(
110
+ wsId,
111
+ args.eventId as string,
112
+ payload,
113
+ options
114
+ );
115
+ return { success: true, event, workspaceId: wsId };
116
+ }
117
+ case 'delete_event':
118
+ return {
119
+ success: true,
120
+ ...(await deleteWorkspaceCalendarEvent(
121
+ wsId,
122
+ args.eventId as string,
123
+ options
124
+ )),
125
+ };
126
+ case 'get_calendar_connections': {
127
+ const [connections, sync] = await Promise.all([
128
+ listCalendarConnections(wsId, options),
129
+ getWorkspaceCalendarSyncStatus(wsId, options),
130
+ ]);
131
+ return { connections, sync };
132
+ }
133
+ case 'connect_google_calendar':
134
+ return getGoogleCalendarAuthUrl(wsId, options);
135
+ case 'sync_calendar':
136
+ return syncWorkspaceCalendar(wsId, options, {
137
+ direction: args.direction as 'inbound' | 'outbound' | 'both',
138
+ });
139
+ case 'set_event_locked':
140
+ return updateWorkspaceCalendarEvent(
141
+ wsId,
142
+ args.eventId as string,
143
+ { locked: args.locked as boolean },
144
+ options
145
+ );
146
+ case 'get_schedulable_tasks':
147
+ return listWorkspaceSchedulableTasks(
148
+ wsId,
149
+ { q: args.query as string | undefined },
150
+ options
151
+ );
152
+ case 'preview_calendar_schedule': {
153
+ const preview =
154
+ await previewWorkspaceCalendarSchedule<SchedulePreview>(
155
+ wsId,
156
+ { windowDays, clientTimezone: ctx.timezone },
157
+ options
158
+ );
159
+ if (!preview.success)
160
+ return {
161
+ success: false,
162
+ error: 'Scheduling preview failed. Nothing was applied.',
163
+ };
164
+ const keys = previews.get(state) ?? new Set<string>();
165
+ keys.add(previewKey);
166
+ previews.set(state, keys);
167
+ return {
168
+ success: true,
169
+ summary: preview.preview?.summary,
170
+ warnings: preview.preview?.warnings,
171
+ events: preview.preview?.events?.slice(0, 50),
172
+ totalEvents: preview.preview?.events?.length ?? 0,
173
+ lockedEventCount: preview.lockedEvents?.length ?? 0,
174
+ tasks: preview.tasks,
175
+ habits: preview.habits,
176
+ };
177
+ }
178
+ case 'apply_calendar_schedule': {
179
+ if (!previews.get(state)?.has(previewKey))
180
+ return {
181
+ success: false,
182
+ error: 'Preview this workspace and scheduling window first.',
183
+ };
184
+ previews.get(state)?.delete(previewKey);
185
+ return applyWorkspaceCalendarSchedule(
186
+ wsId,
187
+ {
188
+ windowDays,
189
+ clientTimezone: ctx.timezone,
190
+ mode: 'safe-apply',
191
+ scope: 'impacted-only',
192
+ forceReschedule: false,
193
+ },
194
+ options
195
+ );
196
+ }
197
+ default:
198
+ return { success: false, error: 'Unknown calendar operation' };
199
+ }
200
+ },
201
+ catch: (error) =>
202
+ error instanceof Error ? error.message : 'Calendar operation failed',
203
+ }).pipe(
204
+ Effect.catchAll((error) => Effect.succeed({ success: false, error }))
205
+ )
206
+ );
207
+ }
@@ -4,6 +4,7 @@ import timezone from 'dayjs/plugin/timezone';
4
4
  import utc from 'dayjs/plugin/utc';
5
5
  import type { MiraToolContext } from '../mira-tools';
6
6
  import { getWorkspaceContextWorkspaceId } from '../workspace-context';
7
+ import { executeCalendarAutomation } from './calendar-automation';
7
8
  import {
8
9
  decryptEventsForTools,
9
10
  encryptEventFieldsForTools,
@@ -42,7 +43,9 @@ export async function executeGetUpcomingEvents(
42
43
 
43
44
  const { data: events, error } = await ctx.supabase
44
45
  .from('workspace_calendar_events')
45
- .select('id, title, description, start_at, end_at, location, is_encrypted')
46
+ .select(
47
+ 'id, title, description, start_at, end_at, location, is_encrypted, locked'
48
+ )
46
49
  .eq('ws_id', workspaceId)
47
50
  .gte('start_at', rangeStart.toISOString())
48
51
  .lte('start_at', rangeEnd.toISOString())
@@ -75,6 +78,7 @@ export async function executeGetUpcomingEvents(
75
78
  start_at: string;
76
79
  end_at: string;
77
80
  location: string | null;
81
+ locked?: boolean | null;
78
82
  }) => ({
79
83
  id: e.id,
80
84
  title: e.title,
@@ -82,6 +86,7 @@ export async function executeGetUpcomingEvents(
82
86
  start: formatInTz(e.start_at),
83
87
  end: formatInTz(e.end_at),
84
88
  location: e.location,
89
+ locked: e.locked === true,
85
90
  })
86
91
  ),
87
92
  };
@@ -91,6 +96,17 @@ export async function executeCreateEvent(
91
96
  args: Record<string, unknown>,
92
97
  ctx: MiraToolContext
93
98
  ) {
99
+ if (ctx.requestHeaders)
100
+ return executeCalendarAutomation('create_event', args, ctx);
101
+ if (
102
+ args.source !== undefined ||
103
+ args.locked !== undefined ||
104
+ args.color !== undefined
105
+ )
106
+ return {
107
+ error:
108
+ 'Provider destinations, colors and locks require an authenticated Chat or Calendar session. No event changes were saved.',
109
+ };
94
110
  const workspaceId = getWorkspaceContextWorkspaceId(ctx);
95
111
  const title = args.title as string;
96
112
  const description = (args.description as string) ?? '';
@@ -161,6 +177,17 @@ export async function executeUpdateEvent(
161
177
  args: Record<string, unknown>,
162
178
  ctx: MiraToolContext
163
179
  ) {
180
+ if (ctx.requestHeaders)
181
+ return executeCalendarAutomation('update_event', args, ctx);
182
+ if (
183
+ args.source !== undefined ||
184
+ args.locked !== undefined ||
185
+ args.color !== undefined
186
+ )
187
+ return {
188
+ error:
189
+ 'Provider destinations, colors and locks require an authenticated Chat or Calendar session. No event changes were saved.',
190
+ };
164
191
  const workspaceId = getWorkspaceContextWorkspaceId(ctx);
165
192
  const eventId = args.eventId as string;
166
193
  const title = args.title as string | undefined;
@@ -257,6 +284,8 @@ export async function executeDeleteEvent(
257
284
  args: Record<string, unknown>,
258
285
  ctx: MiraToolContext
259
286
  ) {
287
+ if (ctx.requestHeaders)
288
+ return executeCalendarAutomation('delete_event', args, ctx);
260
289
  const workspaceId = getWorkspaceContextWorkspaceId(ctx);
261
290
  const eventId = args.eventId as string;
262
291
 
@@ -17,22 +17,22 @@ type CalendarEventRow = {
17
17
 
18
18
  /**
19
19
  * Retrieve the workspace encryption key (read-only).
20
- * Returns `null` when E2EE is not configured for the workspace.
20
+ * Strict mode throws on key/configuration failures; null means no workspace key.
21
21
  */
22
22
  export async function getWorkspaceKeyForTools(
23
- wsId: string
23
+ wsId: string,
24
+ strict = false
24
25
  ): Promise<Buffer | null> {
25
26
  try {
26
27
  const { isEncryptionEnabled, getMasterKey, decryptWorkspaceKey } =
27
28
  await import('@tuturuuu/utils/encryption');
28
29
 
29
- if (!isEncryptionEnabled()) return null;
30
+ if (!strict && !isEncryptionEnabled()) return null;
30
31
 
31
32
  const { createAdminClient } = await import(
32
33
  '@tuturuuu/supabase/next/server'
33
34
  );
34
35
  const sbAdmin = await createAdminClient();
35
- const masterKey = getMasterKey();
36
36
 
37
37
  const { data, error } = await sbAdmin
38
38
  .from('workspace_encryption_keys')
@@ -40,13 +40,15 @@ export async function getWorkspaceKeyForTools(
40
40
  .eq('ws_id', wsId)
41
41
  .maybeSingle();
42
42
 
43
- if (error || !data) return null;
43
+ if (error) throw error;
44
+ if (!data) return null;
44
45
 
45
46
  return await decryptWorkspaceKey(
46
47
  (data as { encrypted_key: string }).encrypted_key,
47
- masterKey
48
+ getMasterKey()
48
49
  );
49
- } catch {
50
+ } catch (error) {
51
+ if (strict) throw error;
50
52
  return null;
51
53
  }
52
54
  }
@@ -74,14 +76,15 @@ export async function decryptEventsForTools<T extends CalendarEventRow>(
74
76
  */
75
77
  export async function encryptEventFieldsForTools(
76
78
  fields: { title: string; description: string; location: string | null },
77
- wsId: string
79
+ wsId: string,
80
+ strict = false
78
81
  ): Promise<{
79
82
  title: string;
80
83
  description: string;
81
84
  location: string | null;
82
85
  is_encrypted: boolean;
83
86
  }> {
84
- const key = await getWorkspaceKeyForTools(wsId);
87
+ const key = await getWorkspaceKeyForTools(wsId, strict);
85
88
  if (!key) {
86
89
  return { ...fields, is_encrypted: false };
87
90
  }
@@ -2,10 +2,11 @@ import { google } from '@ai-sdk/google';
2
2
  import { generateText, stepCountIs } from 'ai';
3
3
  import { z } from 'zod';
4
4
  import { withAiMemory } from '../../memory';
5
+ import { isGoogleSearchToolName } from '../google-search-events';
5
6
  import { createGoogleSearchToolSet } from '../google-search-tool';
6
7
  import type { MiraToolContext } from '../mira-tools';
7
8
 
8
- const SEARCH_WRAPPER_MODEL = 'gemini-3.1-flash-lite';
9
+ const SEARCH_WRAPPER_MODEL = 'gemini-3.5-flash-lite';
9
10
 
10
11
  type SearchSource = {
11
12
  sourceId?: string;
@@ -34,16 +35,35 @@ function hasGoogleSearchCallInSteps(steps: unknown): boolean {
34
35
  return steps.some((step) => {
35
36
  if (!step || typeof step !== 'object') return false;
36
37
  const typedStep = step as ToolStepLike;
37
- const called = (typedStep.toolCalls ?? []).some(
38
- (toolCall) => toolCall.toolName === 'google_search'
38
+ const called = (typedStep.toolCalls ?? []).some((toolCall) =>
39
+ isGoogleSearchToolName(toolCall.toolName)
39
40
  );
40
- const hasResult = (typedStep.toolResults ?? []).some(
41
- (toolResult) => toolResult.toolName === 'google_search'
41
+ const hasResult = (typedStep.toolResults ?? []).some((toolResult) =>
42
+ isGoogleSearchToolName(toolResult.toolName)
42
43
  );
43
44
  return called || hasResult;
44
45
  });
45
46
  }
46
47
 
48
+ function hasGroundingQueries(value: unknown): boolean {
49
+ if (!value || typeof value !== 'object') return false;
50
+ const result = value as {
51
+ providerMetadata?: {
52
+ google?: { groundingMetadata?: { webSearchQueries?: unknown } };
53
+ };
54
+ steps?: unknown[];
55
+ };
56
+ const queries =
57
+ result.providerMetadata?.google?.groundingMetadata?.webSearchQueries;
58
+ return (
59
+ (Array.isArray(queries) &&
60
+ queries.some(
61
+ (query) => typeof query === 'string' && query.trim().length > 0
62
+ )) ||
63
+ (result.steps?.some(hasGroundingQueries) ?? false)
64
+ );
65
+ }
66
+
47
67
  function normalizeSources(value: unknown): SearchSource[] {
48
68
  if (!Array.isArray(value)) return [];
49
69
 
@@ -88,8 +108,11 @@ async function runGoogleSearchWrapper(
88
108
  }),
89
109
  tools: createGoogleSearchToolSet(),
90
110
  prompt,
91
- stopWhen: stepCountIs(4),
92
- ...(forceTool ? { toolChoice: 'required' as const } : {}),
111
+ stopWhen: stepCountIs(1),
112
+ maxRetries: 0,
113
+ maxOutputTokens: 2048,
114
+ abortSignal: AbortSignal.timeout(30_000),
115
+ toolChoice: 'auto',
93
116
  });
94
117
  }
95
118
 
@@ -114,7 +137,7 @@ export async function executeGoogleSearch(
114
137
  (result as { steps?: unknown }).steps
115
138
  );
116
139
 
117
- if (!wasToolCalled) {
140
+ if (!wasToolCalled && !hasGroundingQueries(result)) {
118
141
  result = await runGoogleSearchWrapper(query, true, ctx);
119
142
  sources = normalizeSources((result as { sources?: unknown }).sources);
120
143
  wasToolCalled = hasGoogleSearchCallInSteps(
@@ -122,7 +145,7 @@ export async function executeGoogleSearch(
122
145
  );
123
146
  }
124
147
 
125
- if (!wasToolCalled && sources.length === 0) {
148
+ if (!wasToolCalled && !hasGroundingQueries(result)) {
126
149
  return {
127
150
  ok: false,
128
151
  query,
@@ -130,6 +153,14 @@ export async function executeGoogleSearch(
130
153
  };
131
154
  }
132
155
 
156
+ if (!result.text.trim())
157
+ return {
158
+ ok: false,
159
+ query,
160
+ error:
161
+ 'Search completed without a final answer. Try a more specific query.',
162
+ };
163
+
133
164
  return {
134
165
  ok: true,
135
166
  query,
@@ -0,0 +1,232 @@
1
+ import type { Enums } from '@tuturuuu/types';
2
+ import { parseTaskDateToUTCISO } from '@tuturuuu/utils/task-date-timezone';
3
+ import type { MiraToolContext } from '../mira-tool-types';
4
+ import { getWorkspaceContextWorkspaceId } from '../workspace-context';
5
+
6
+ export async function executeCreateTask(
7
+ args: Record<string, unknown>,
8
+ ctx: MiraToolContext
9
+ ) {
10
+ const { supabase } = ctx;
11
+ const wsId = getWorkspaceContextWorkspaceId(ctx);
12
+ const boardIdArg = args.boardId as string | undefined;
13
+ const listIdArg = args.listId as string | undefined;
14
+
15
+ // If listId is provided directly, validate it exists and use it
16
+ if (listIdArg) {
17
+ const { data: targetList, error: listError } = await supabase
18
+ .from('task_lists')
19
+ .select('id, board_id, workspace_boards!inner(ws_id)')
20
+ .eq('id', listIdArg)
21
+ .not('status', 'in', '(done,closed)')
22
+ .is('workspace_boards.archived_at', null)
23
+ .is('workspace_boards.deleted_at', null)
24
+ .eq('archived', false)
25
+ .eq('deleted', false)
26
+ .maybeSingle();
27
+ if (listError) return { created: false, error: listError.message };
28
+
29
+ if (!targetList) {
30
+ return {
31
+ created: false,
32
+ error: `Task list "${listIdArg}" not found or archived. Use list_task_lists to discover valid lists.`,
33
+ };
34
+ }
35
+
36
+ const relation = targetList.workspace_boards as unknown as
37
+ | { ws_id: string }
38
+ | { ws_id: string }[];
39
+ const listWsId = (Array.isArray(relation) ? relation[0] : relation)?.ws_id;
40
+ if (listWsId !== wsId) {
41
+ return {
42
+ created: false,
43
+ error: `Task list does not belong to the current workspace context. Switch workspace first with set_workspace_context.`,
44
+ };
45
+ }
46
+
47
+ if (boardIdArg && targetList.board_id !== boardIdArg) {
48
+ return {
49
+ created: false,
50
+ error: 'The list does not belong to the specified board.',
51
+ };
52
+ }
53
+ return persistTask(args, ctx, listIdArg);
54
+ }
55
+
56
+ // Resolve board: use provided boardId or fall back to first workspace board
57
+ let board: { id: string } | null = null;
58
+
59
+ if (boardIdArg) {
60
+ const { data: targetBoard, error: boardError } = await supabase
61
+ .from('workspace_boards')
62
+ .select('id')
63
+ .eq('id', boardIdArg)
64
+ .eq('ws_id', wsId)
65
+ .is('deleted_at', null)
66
+ .is('archived_at', null)
67
+ .maybeSingle();
68
+ if (boardError) return { created: false, error: boardError.message };
69
+
70
+ if (!targetBoard) {
71
+ return {
72
+ created: false,
73
+ error: `Board "${boardIdArg}" not found in this workspace. Use list_boards to discover valid boards.`,
74
+ };
75
+ }
76
+ board = targetBoard;
77
+ } else {
78
+ const { data: firstBoard, error: lookupError } = await supabase
79
+ .from('workspace_boards')
80
+ .select('id')
81
+ .eq('ws_id', wsId)
82
+ .is('deleted_at', null)
83
+ .is('archived_at', null)
84
+ .order('created_at', { ascending: true })
85
+ .limit(1)
86
+ .maybeSingle();
87
+ if (lookupError) return { created: false, error: lookupError.message };
88
+ board = firstBoard;
89
+ }
90
+
91
+ if (!board) {
92
+ const { data: newBoard, error: boardErr } = await supabase
93
+ .from('workspace_boards')
94
+ .insert({ name: 'Tasks', ws_id: wsId })
95
+ .select('id')
96
+ .single();
97
+ if (boardErr || !newBoard)
98
+ return {
99
+ created: false,
100
+ writeUncertain: true,
101
+ error: `Failed to create board: ${boardErr?.message ?? 'Unknown error'}`,
102
+ };
103
+ board = newBoard;
104
+ }
105
+
106
+ const { data: existingList, error: lookupError } = await supabase
107
+ .from('task_lists')
108
+ .select('id')
109
+ .eq('board_id', board.id)
110
+ .not('status', 'in', '(done,closed)')
111
+ .eq('archived', false)
112
+ .eq('deleted', false)
113
+ .order('created_at', { ascending: true })
114
+ .limit(1)
115
+ .maybeSingle();
116
+ if (lookupError) return { created: false, error: lookupError.message };
117
+
118
+ let list = existingList;
119
+ if (!list) {
120
+ const { data: newList, error: listErr } = await supabase
121
+ .from('task_lists')
122
+ .insert({ name: 'To Do', board_id: board.id })
123
+ .select('id')
124
+ .single();
125
+ if (listErr || !newList)
126
+ return {
127
+ created: false,
128
+ writeUncertain: true,
129
+ error: `Failed to create list: ${listErr?.message ?? 'Unknown error'}`,
130
+ };
131
+ list = newList;
132
+ }
133
+
134
+ return persistTask(args, ctx, list.id);
135
+ }
136
+
137
+ async function persistTask(
138
+ args: Record<string, unknown>,
139
+ ctx: MiraToolContext,
140
+ listId: string
141
+ ) {
142
+ const { userId, supabase } = ctx;
143
+ const name = args.name as string;
144
+ const description = args.description as string | null;
145
+ const priority = (args.priority as Enums<'task_priority'> | null) ?? null;
146
+ const assignToSelf = args.assignToSelf !== false;
147
+ const date = (value: unknown, end: boolean) =>
148
+ typeof value === 'string'
149
+ ? ctx.timezone
150
+ ? parseTaskDateToUTCISO(value, ctx.timezone, end)
151
+ : value
152
+ : null;
153
+ const { data: task, error } = await supabase
154
+ .from('tasks')
155
+ .insert({
156
+ creator_id: userId,
157
+ name,
158
+ description: description
159
+ ? JSON.stringify({
160
+ type: 'doc',
161
+ content: [
162
+ {
163
+ type: 'paragraph',
164
+ content: [{ type: 'text', text: description }],
165
+ },
166
+ ],
167
+ })
168
+ : null,
169
+ list_id: listId,
170
+ priority,
171
+ start_date: date(args.startDate, false),
172
+ end_date: date(args.endDate ?? args.dueDate, true),
173
+ estimation_points: (args.estimationPoints as number | null) ?? null,
174
+ completed: false,
175
+ })
176
+ .select('id')
177
+ .single();
178
+ if (error || !task) {
179
+ // PostgreSQL data/constraint/permission errors reject the insert statement.
180
+ // Unknown or transport errors can lose the response after a committed write.
181
+ const rejected =
182
+ /^(22|23)/.test(error?.code ?? '') || error?.code === '42501';
183
+ return {
184
+ success: false,
185
+ ...(rejected ? { created: false } : { writeUncertain: true }),
186
+ error: error?.message ?? 'Task creation returned no saved task.',
187
+ instruction: rejected
188
+ ? 'The database rejected the insert. Correct the reported problem before retrying.'
189
+ : 'The insert could not be verified. Check existing tasks before attempting creation again.',
190
+ };
191
+ }
192
+
193
+ let assignmentError: string | undefined;
194
+ if (assignToSelf) {
195
+ const { error } = await supabase
196
+ .from('task_assignees')
197
+ .insert({ task_id: task.id, user_id: userId });
198
+ assignmentError = error?.message;
199
+ }
200
+ // Read the saved row and its location before claiming completion. Never retry
201
+ // a write when this read fails: the ID is returned for recovery instead.
202
+ const { data: saved, error: readError } = await supabase
203
+ .from('tasks')
204
+ .select(
205
+ 'id, name, priority, start_date, end_date, estimation_points, created_at, task_lists!inner(id, name, workspace_boards!inner(id, name, ws_id))'
206
+ )
207
+ .eq('id', task.id)
208
+ .single();
209
+ if (readError || !saved)
210
+ return {
211
+ success: false,
212
+ created: true,
213
+ taskId: task.id,
214
+ error:
215
+ 'Task was inserted but its saved details could not be verified. Do not create it again; retrieve this task ID.',
216
+ ...(assignmentError ? { assignmentError } : {}),
217
+ };
218
+ return {
219
+ success: !assignmentError,
220
+ created: true,
221
+ task: saved,
222
+ workspaceId: getWorkspaceContextWorkspaceId(ctx),
223
+ assignedToSelf: assignToSelf && !assignmentError,
224
+ ...(assignmentError
225
+ ? {
226
+ error: `Task was created, but assignment failed: ${assignmentError}. Retry assignment only, never creation.`,
227
+ }
228
+ : {}),
229
+ message:
230
+ 'Use the saved task ID, location and due date in your confirmation. Do not claim any fields absent from this result.',
231
+ };
232
+ }