@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,59 @@
1
+ import {
2
+ withForwardedInternalApiAuth,
3
+ withTaskApiBaseUrl,
4
+ } from '@tuturuuu/internal-api/client';
5
+ import { getUserTaskDashboard } from '@tuturuuu/internal-api/tasks';
6
+ import { Effect } from '@tuturuuu/utils/effect';
7
+ import type { MiraToolContext } from '../mira-tool-types';
8
+ import { getWorkspaceContextWorkspaceId } from '../workspace-context';
9
+
10
+ export function executeTaskDashboard(
11
+ args: Record<string, unknown>,
12
+ ctx: MiraToolContext
13
+ ) {
14
+ const category = args.category ?? args.status ?? 'all';
15
+ const options = withForwardedInternalApiAuth(
16
+ ctx.requestHeaders!,
17
+ withTaskApiBaseUrl()
18
+ );
19
+ return Effect.runPromise(
20
+ Effect.tryPromise({
21
+ try: async () => {
22
+ const result = await getUserTaskDashboard(
23
+ {
24
+ wsId: getWorkspaceContextWorkspaceId(ctx),
25
+ isPersonal: ctx.workspaceContext?.personal === true,
26
+ },
27
+ options
28
+ );
29
+ const output: Record<string, unknown> = {
30
+ totalActive: result.totalActiveTasks,
31
+ };
32
+ for (const group of ['overdue', 'today', 'upcoming'] as const) {
33
+ if (category !== 'all' && category !== group) continue;
34
+ output[group] = {
35
+ count: result[group].length,
36
+ hasMore: result[group].length > 30,
37
+ tasks: result[group].slice(0, 30).map((task) => ({
38
+ id: task.id,
39
+ name: task.name,
40
+ priority: task.priority,
41
+ dueDate: task.end_date,
42
+ workspaceId: task.list?.board?.ws_id,
43
+ workspaceName: task.list?.board?.workspaces?.name,
44
+ boardId: task.list?.board?.id,
45
+ boardName: task.list?.board?.name,
46
+ listId: task.list_id,
47
+ listName: task.list?.name,
48
+ })),
49
+ };
50
+ }
51
+ return output;
52
+ },
53
+ catch: (error) =>
54
+ error instanceof Error ? error.message : 'Task feed unavailable',
55
+ }).pipe(
56
+ Effect.catchAll((error) => Effect.succeed({ success: false, error }))
57
+ )
58
+ );
59
+ }
@@ -0,0 +1,76 @@
1
+ import type { MiraToolContext } from '../mira-tool-types';
2
+ import { getWorkspaceContextWorkspaceId } from '../workspace-context';
3
+
4
+ export async function executeGetTask(
5
+ args: Record<string, unknown>,
6
+ ctx: MiraToolContext
7
+ ) {
8
+ const taskId = args.taskId as string;
9
+ const wsId = getWorkspaceContextWorkspaceId(ctx);
10
+ const { data, error } = await ctx.supabase
11
+ .from('tasks')
12
+ .select(
13
+ 'id, name, description, priority, start_date, end_date, estimation_points, completed, completed_at, board_id, list_id, direct_board:workspace_boards!fk_tasks_board_id(id, name, ws_id), task_lists(id, name, workspace_boards!inner(id, name, ws_id)), task_assignees(user_id), task_labels(label_id)'
14
+ )
15
+ .eq('id', taskId)
16
+ .eq('direct_board.ws_id', wsId)
17
+ .eq('task_lists.workspace_boards.ws_id', wsId)
18
+ .or('direct_board.not.is.null,and(board_id.is.null,task_lists.not.is.null)')
19
+ .maybeSingle();
20
+ return error
21
+ ? { error: error.message }
22
+ : data
23
+ ? { task: data, workspaceId: wsId }
24
+ : { error: 'Task not found in the current workspace.' };
25
+ }
26
+
27
+ export async function executeSearchTasks(
28
+ args: Record<string, unknown>,
29
+ ctx: MiraToolContext
30
+ ) {
31
+ const wsId = getWorkspaceContextWorkspaceId(ctx);
32
+ const title = String(args.query ?? '').trim();
33
+ if (!title) return { error: 'Provide a task title to search for.' };
34
+ const page = Math.max(1, Number(args.page) || 1);
35
+ const limit = Math.min(50, Math.max(1, Number(args.limit) || 20));
36
+ const query = title.replace(/[\\%_]/g, '\\$&');
37
+ let builder = ctx.supabase
38
+ .from('tasks')
39
+ .select(
40
+ 'id, name, priority, start_date, end_date, completed, board_id, list_id, direct_board:workspace_boards!fk_tasks_board_id(id, name, ws_id), task_lists(id, name, workspace_boards!inner(id, name, ws_id))',
41
+ { count: 'exact' }
42
+ )
43
+ .eq('direct_board.ws_id', wsId)
44
+ .is('direct_board.archived_at', null)
45
+ .is('direct_board.deleted_at', null)
46
+ .eq('task_lists.workspace_boards.ws_id', wsId)
47
+ .is('task_lists.workspace_boards.archived_at', null)
48
+ .is('task_lists.workspace_boards.deleted_at', null)
49
+ .eq('task_lists.archived', false)
50
+ .eq('task_lists.deleted', false)
51
+ // Board ownership takes precedence, matching getTaskScope. Nullable embeds
52
+ // allow board-only tasks without admitting rows from another workspace.
53
+ .or(
54
+ 'and(direct_board.not.is.null,list_id.is.null),and(direct_board.not.is.null,task_lists.not.is.null),and(board_id.is.null,task_lists.not.is.null)'
55
+ )
56
+ .is('deleted_at', null)
57
+ .ilike('name', `%${query}%`)
58
+ .order('created_at', { ascending: false })
59
+ .order('id', { ascending: true })
60
+ .range((page - 1) * limit, page * limit - 1);
61
+ if (args.includeCompleted !== true)
62
+ builder = builder
63
+ .eq('completed', false)
64
+ .is('completed_at', null)
65
+ .is('closed_at', null);
66
+ const { data, count, error } = await builder;
67
+ return error
68
+ ? { error: error.message }
69
+ : {
70
+ workspaceId: wsId,
71
+ tasks: data,
72
+ total: count,
73
+ page,
74
+ hasMore: page * limit < (count ?? 0),
75
+ };
76
+ }
@@ -12,6 +12,7 @@ import {
12
12
  isWorkspaceMember,
13
13
  type TaskScope,
14
14
  } from './scope-helpers';
15
+ import { executeTaskDashboard } from './task-dashboard';
15
16
 
16
17
  type RpcTask = {
17
18
  task_id: string;
@@ -45,6 +46,7 @@ export async function executeGetMyTasks(
45
46
  args: Record<string, unknown>,
46
47
  ctx: MiraToolContext
47
48
  ) {
49
+ if (ctx.requestHeaders) return executeTaskDashboard(args, ctx);
48
50
  const { userId, supabase } = ctx;
49
51
  const wsId = getWorkspaceContextWorkspaceId(ctx);
50
52
  const category = ((args.category ?? args.status) as string) || 'all';
@@ -101,203 +103,7 @@ export async function executeGetMyTasks(
101
103
  return result;
102
104
  }
103
105
 
104
- export async function executeCreateTask(
105
- args: Record<string, unknown>,
106
- ctx: MiraToolContext
107
- ) {
108
- const { userId, supabase } = ctx;
109
- const wsId = getWorkspaceContextWorkspaceId(ctx);
110
- const name = args.name as string;
111
- const description = args.description as string | null;
112
- const priority = (args.priority as Enums<'task_priority'> | null) ?? null;
113
- const boardIdArg = args.boardId as string | undefined;
114
- const listIdArg = args.listId as string | undefined;
115
- const assignToSelf = (args.assignToSelf as boolean | undefined) !== false;
116
-
117
- // If listId is provided directly, validate it exists and use it
118
- if (listIdArg) {
119
- const { data: targetList } = await supabase
120
- .from('task_lists')
121
- .select('id, board_id, workspace_boards!inner(ws_id)')
122
- .eq('id', listIdArg)
123
- .eq('archived', false)
124
- .single();
125
-
126
- if (!targetList) {
127
- return {
128
- error: `Task list "${listIdArg}" not found or archived. Use list_task_lists to discover valid lists.`,
129
- };
130
- }
131
-
132
- const listWsId = (
133
- targetList.workspace_boards as unknown as { ws_id: string }
134
- )?.ws_id;
135
- if (listWsId && listWsId !== wsId) {
136
- return {
137
- error: `Task list does not belong to the current workspace context. Switch workspace first with set_workspace_context.`,
138
- };
139
- }
140
-
141
- // Use the validated list directly — skip board/list auto-resolution
142
- const { data: task, error } = await supabase
143
- .from('tasks')
144
- .insert({
145
- name,
146
- description: description
147
- ? JSON.stringify({
148
- type: 'doc',
149
- content: [
150
- {
151
- type: 'paragraph',
152
- content: [{ type: 'text', text: description }],
153
- },
154
- ],
155
- })
156
- : null,
157
- list_id: listIdArg,
158
- priority,
159
- completed: false,
160
- })
161
- .select('id, name, priority, created_at')
162
- .single();
163
-
164
- if (error) return { error: error.message };
165
-
166
- if (assignToSelf && task) {
167
- const { error: assignErr } = await supabase
168
- .from('task_assignees')
169
- .insert({ task_id: task.id, user_id: userId });
170
- if (assignErr) {
171
- return {
172
- success: true,
173
- message: `Task "${name}" created in specified list, but auto-assignment failed: ${assignErr.message}`,
174
- task,
175
- };
176
- }
177
- return {
178
- success: true,
179
- message: `Task "${name}" created in specified list and assigned to you`,
180
- task,
181
- };
182
- }
183
-
184
- return {
185
- success: true,
186
- message: `Task "${name}" created in specified list (unassigned)`,
187
- task,
188
- };
189
- }
190
-
191
- // Resolve board: use provided boardId or fall back to first workspace board
192
- let board: { id: string } | null = null;
193
-
194
- if (boardIdArg) {
195
- const { data: targetBoard } = await supabase
196
- .from('workspace_boards')
197
- .select('id')
198
- .eq('id', boardIdArg)
199
- .eq('ws_id', wsId)
200
- .single();
201
-
202
- if (!targetBoard) {
203
- return {
204
- error: `Board "${boardIdArg}" not found in this workspace. Use list_boards to discover valid boards.`,
205
- };
206
- }
207
- board = targetBoard;
208
- } else {
209
- const { data: firstBoard } = await supabase
210
- .from('workspace_boards')
211
- .select('id')
212
- .eq('ws_id', wsId)
213
- .limit(1)
214
- .single();
215
- board = firstBoard;
216
- }
217
-
218
- if (!board) {
219
- const { data: newBoard, error: boardErr } = await supabase
220
- .from('workspace_boards')
221
- .insert({ name: 'Tasks', ws_id: wsId })
222
- .select('id')
223
- .single();
224
- if (boardErr || !newBoard)
225
- return {
226
- error: `Failed to create board: ${boardErr?.message ?? 'Unknown error'}`,
227
- };
228
- board = newBoard;
229
- }
230
-
231
- let { data: list } = await supabase
232
- .from('task_lists')
233
- .select('id')
234
- .eq('board_id', board.id)
235
- .eq('archived', false)
236
- .order('created_at', { ascending: true })
237
- .limit(1)
238
- .single();
239
-
240
- if (!list) {
241
- const { data: newList, error: listErr } = await supabase
242
- .from('task_lists')
243
- .insert({ name: 'To Do', board_id: board.id })
244
- .select('id')
245
- .single();
246
- if (listErr || !newList)
247
- return {
248
- error: `Failed to create list: ${listErr?.message ?? 'Unknown error'}`,
249
- };
250
- list = newList;
251
- }
252
-
253
- const { data: task, error } = await supabase
254
- .from('tasks')
255
- .insert({
256
- name,
257
- description: description
258
- ? JSON.stringify({
259
- type: 'doc',
260
- content: [
261
- {
262
- type: 'paragraph',
263
- content: [{ type: 'text', text: description }],
264
- },
265
- ],
266
- })
267
- : null,
268
- list_id: list.id,
269
- priority,
270
- completed: false,
271
- })
272
- .select('id, name, priority, created_at')
273
- .single();
274
-
275
- if (error) return { error: error.message };
276
-
277
- if (assignToSelf && task) {
278
- const { error: assignErr } = await supabase
279
- .from('task_assignees')
280
- .insert({ task_id: task.id, user_id: userId });
281
- if (assignErr) {
282
- return {
283
- success: true,
284
- message: `Task "${name}" created, but auto-assignment failed: ${assignErr.message}`,
285
- task,
286
- };
287
- }
288
- return {
289
- success: true,
290
- message: `Task "${name}" created and assigned to you`,
291
- task,
292
- };
293
- }
294
-
295
- return {
296
- success: true,
297
- message: `Task "${name}" created (unassigned)`,
298
- task,
299
- };
300
- }
106
+ export { executeCreateTask } from './task-create';
301
107
 
302
108
  export async function executeCompleteTask(
303
109
  args: Record<string, unknown>,
@@ -323,7 +129,11 @@ export async function executeCompleteTask(
323
129
 
324
130
  const { error } = await ctx.supabase
325
131
  .from('tasks')
326
- .update({ completed: true, completed_at: new Date().toISOString() })
132
+ .update({
133
+ completed: true,
134
+ completed_at: new Date().toISOString(),
135
+ closed_at: new Date().toISOString(),
136
+ })
327
137
  .eq('id', taskId)
328
138
  .eq(taskScopePredicate.column, taskScopePredicate.value);
329
139
 
@@ -334,7 +144,7 @@ export async function executeCompleteTask(
334
144
  // ── New CRUD tools ──
335
145
 
336
146
  const UPDATE_TASK_FIELDS_HINT =
337
- 'Accepted fields: taskId (or id), endDate (or dueDate, ISO), name, description, priority, startDate, listId, estimationPoints.';
147
+ 'Accepted fields: taskId (or id), endDate (or dueDate, ISO), name, description, priority, startDate, listId, estimationPoints, completed.';
338
148
 
339
149
  export async function executeUpdateTask(
340
150
  args: Record<string, unknown>,
@@ -360,6 +170,11 @@ export async function executeUpdateTask(
360
170
  })
361
171
  : null;
362
172
  }
173
+ if (args.completed !== undefined) {
174
+ updates.completed = args.completed as boolean;
175
+ updates.completed_at = args.completed ? new Date().toISOString() : null;
176
+ updates.closed_at = updates.completed_at;
177
+ }
363
178
  if (args.priority !== undefined)
364
179
  updates.priority = args.priority as Enums<'task_priority'>;
365
180
  if (args.startDate !== undefined) {
@@ -371,7 +186,7 @@ export async function executeUpdateTask(
371
186
  ? parseTaskDateToUTCISO(v, ctx.timezone, false)
372
187
  : v;
373
188
  }
374
- const endDateValue = args.endDate ?? args.dueDate;
189
+ const endDateValue = args.endDate !== undefined ? args.endDate : args.dueDate;
375
190
  if (endDateValue !== undefined) {
376
191
  const v = endDateValue as string | null;
377
192
  updates.end_date =
@@ -420,14 +235,43 @@ export async function executeUpdateTask(
420
235
  return { error: 'Task not found in current workspace' };
421
236
  }
422
237
 
423
- const { error } = await ctx.supabase
238
+ if (args.completed === false) {
239
+ const listId = (args.listId as string | undefined) ?? taskScope.listId;
240
+ const { data: list, error } = await ctx.supabase
241
+ .from('task_lists')
242
+ .select('id, status')
243
+ .eq('id', listId ?? '')
244
+ .single();
245
+ if (
246
+ error ||
247
+ !list ||
248
+ !['not_started', 'active'].includes(list.status ?? '')
249
+ )
250
+ return {
251
+ error:
252
+ 'To reopen this task, provide an active listId from list_task_lists. No changes were saved.',
253
+ };
254
+ }
255
+ const { data: saved, error } = await ctx.supabase
424
256
  .from('tasks')
425
257
  .update(updates)
426
258
  .eq('id', taskId)
427
- .eq(taskScopePredicate.column, taskScopePredicate.value);
259
+ .eq(taskScopePredicate.column, taskScopePredicate.value)
260
+ .select('id, name, priority, start_date, end_date, completed, list_id')
261
+ .maybeSingle();
428
262
 
429
- if (error) return { error: error.message };
430
- return { success: true, message: `Task ${taskId} updated` };
263
+ if (error || !saved)
264
+ return {
265
+ success: false,
266
+ error:
267
+ error?.message ??
268
+ 'Task was not updated; it may have moved or been removed.',
269
+ };
270
+ return {
271
+ success: true,
272
+ task: saved,
273
+ workspaceId: getWorkspaceContextWorkspaceId(ctx),
274
+ };
431
275
  }
432
276
 
433
277
  export async function executeDeleteTask(
@@ -559,9 +403,10 @@ export async function executeListTaskLists(
559
403
 
560
404
  const { data, error } = await ctx.supabase
561
405
  .from('task_lists')
562
- .select('id, name, board_id, color, position, archived')
406
+ .select('id, name, board_id, status, color, position, archived')
563
407
  .eq('board_id', boardId)
564
408
  .eq('archived', false)
409
+ .eq('deleted', false)
565
410
  .order('position', { ascending: true });
566
411
 
567
412
  if (error) return { error: error.message };
@@ -1,4 +1,9 @@
1
+ import {
2
+ manageWorkspaceSchema,
3
+ showWorkspaceArtifactSchema,
4
+ } from '../../workspace-artifacts';
1
5
  import type { MiraToolContext } from '../mira-tools';
6
+ import { getWorkspaceContextWorkspaceId } from '../workspace-context';
2
7
 
3
8
  export async function executeSetTheme(
4
9
  args: Record<string, unknown>,
@@ -21,3 +26,46 @@ export async function executeSetTheme(
21
26
  message: `Theme changed to ${theme}`,
22
27
  };
23
28
  }
29
+
30
+ export async function executeSetSidebar(args: Record<string, unknown>) {
31
+ const behavior = args.behavior;
32
+ if (
33
+ !['expanded', 'collapsed', 'hover', 'hidden'].includes(String(behavior))
34
+ ) {
35
+ return { error: 'Invalid sidebar behavior' };
36
+ }
37
+ return { success: true, action: 'set_sidebar', behavior };
38
+ }
39
+
40
+ export async function executeShowWorkspaceArtifact(
41
+ args: Record<string, unknown>,
42
+ ctx: MiraToolContext
43
+ ) {
44
+ const parsed = showWorkspaceArtifactSchema.safeParse(args);
45
+ if (!parsed.success)
46
+ return { error: 'Invalid artifact presentation or layout' };
47
+ return {
48
+ success: true,
49
+ action: 'show_workspace_artifact',
50
+ message:
51
+ 'Artifact UI command completed. This UI command did not fetch product data; the panel fetches its own data separately. Do not repeat this successful command. Continue only with other requested actions, then answer.',
52
+ ...parsed.data,
53
+ wsId: getWorkspaceContextWorkspaceId(ctx),
54
+ };
55
+ }
56
+
57
+ export async function executeManageWorkspace(
58
+ args: Record<string, unknown>,
59
+ ctx: MiraToolContext
60
+ ) {
61
+ const parsed = manageWorkspaceSchema.safeParse(args);
62
+ if (!parsed.success) return { error: 'Invalid workspace operation' };
63
+ return {
64
+ success: true,
65
+ action: 'manage_workspace',
66
+ message:
67
+ 'Workspace UI command completed. Do not repeat this successful command to verify it. Preserve panels the user asked to keep; once the requested actions are complete, answer without further UI calls.',
68
+ ...parsed.data,
69
+ wsId: getWorkspaceContextWorkspaceId(ctx),
70
+ };
71
+ }
@@ -0,0 +1,4 @@
1
+ /** Gemini native search emits server tools rather than the registered tool name. */
2
+ export function isGoogleSearchToolName(name: string | undefined) {
3
+ return name === 'google_search' || name === 'server:GOOGLE_SEARCH_WEB';
4
+ }
@@ -0,0 +1,73 @@
1
+ import type { PermissionId } from '@tuturuuu/types';
2
+ import { type ToolSet, toolSearch } from 'ai';
3
+ import { MIRA_TOOL_PERMISSIONS } from './mira-tool-metadata';
4
+ import type { MiraToolName } from './mira-tool-names';
5
+ import type { MiraToolContext } from './mira-tool-types';
6
+ import { getWorkspaceContextWorkspaceId } from './workspace-context';
7
+
8
+ type StepPolicy = {
9
+ activeTools: string[];
10
+ toolChoice?: 'required' | 'none';
11
+ };
12
+
13
+ /** Each generation owns discovery state; execution retains the original guards. */
14
+ export function createMiraSdkToolSearch(
15
+ originalTools: ToolSet,
16
+ ctx: MiraToolContext,
17
+ withoutPermission?: (permission: PermissionId) => boolean
18
+ ) {
19
+ const tools: ToolSet = Object.fromEntries(
20
+ Object.entries(originalTools).map(([name, definition]) => [
21
+ name,
22
+ name === 'search_tools'
23
+ ? toolSearch()
24
+ : { ...definition, deferLoading: true },
25
+ ])
26
+ );
27
+
28
+ async function prepareStep(policy: StepPolicy) {
29
+ if (policy.toolChoice === 'none') {
30
+ return { ...policy, activeTools: [] };
31
+ }
32
+ const workspaceId = getWorkspaceContextWorkspaceId(ctx);
33
+ const allowed = new Set<string>();
34
+ for (const name of Object.keys(tools)) {
35
+ const permission = MIRA_TOOL_PERMISSIONS[name as MiraToolName];
36
+ const required = permission
37
+ ? Array.isArray(permission)
38
+ ? permission
39
+ : [permission]
40
+ : [];
41
+ let authorized = required.length === 0;
42
+ if (!authorized) {
43
+ try {
44
+ authorized = ctx.authorizeWorkspaceTools
45
+ ? await ctx.authorizeWorkspaceTools(workspaceId, required)
46
+ : Boolean(withoutPermission) &&
47
+ !required.some((item) => withoutPermission!(item));
48
+ } catch {
49
+ authorized = false;
50
+ }
51
+ }
52
+ if (authorized) allowed.add(name);
53
+ }
54
+ // Never publish a catalog authorized for a context that changed meanwhile.
55
+ if (workspaceId !== getWorkspaceContextWorkspaceId(ctx)) {
56
+ return { toolChoice: 'none' as const, activeTools: [] };
57
+ }
58
+ const selected = new Set(policy.activeTools);
59
+ const canDiscover = selected.has('search_tools');
60
+ const activeTools = [...allowed].filter(
61
+ (name) => canDiscover || selected.has(name)
62
+ );
63
+ // prepareStep supports eligibility overrides, not replacement ToolSets.
64
+ // These definitions belong only to this generation, never to the registry.
65
+ for (const [name, definition] of Object.entries(tools)) {
66
+ if (name !== 'search_tools') {
67
+ tools[name] = { ...definition, deferLoading: !selected.has(name) };
68
+ }
69
+ }
70
+ return { ...policy, activeTools };
71
+ }
72
+ return { tools, prepareStep };
73
+ }
@@ -0,0 +1,61 @@
1
+ const object = (value: unknown): value is Record<string, unknown> =>
2
+ !!value && typeof value === 'object' && !Array.isArray(value);
3
+ const dateKey = (value: unknown) => {
4
+ if (typeof value !== 'string') return null;
5
+ const date = new Date(value);
6
+ return Number.isNaN(date.getTime()) ? value : date.toISOString();
7
+ };
8
+
9
+ /** Per-turn protection, retaining receipts whenever a write may have happened. */
10
+ export function createTaskWriteGuard() {
11
+ const pending = new Map<string, Promise<unknown>>();
12
+ return async (
13
+ workspaceId: string,
14
+ args: Record<string, unknown>,
15
+ execute: () => Promise<unknown>
16
+ ) => {
17
+ const { dueDate, ...fields } = args;
18
+ const normalized = {
19
+ ...fields,
20
+ description: args.description || null,
21
+ priority: args.priority ?? null,
22
+ startDate: dateKey(args.startDate),
23
+ endDate: dateKey(args.endDate ?? dueDate),
24
+ estimationPoints: args.estimationPoints ?? null,
25
+ assignToSelf: args.assignToSelf !== false,
26
+ };
27
+ const key = JSON.stringify([
28
+ workspaceId,
29
+ Object.entries(normalized).sort(([a], [b]) => a.localeCompare(b)),
30
+ ]);
31
+ const previous = pending.get(key);
32
+ if (previous) {
33
+ const result = await previous;
34
+ return {
35
+ ...(object(result) ? result : { result }),
36
+ reusedResult: true,
37
+ instruction:
38
+ 'This is the prior creation result. No second task was created. Explain the saved result or error and stop retrying creation.',
39
+ };
40
+ }
41
+ const result = Promise.resolve()
42
+ .then(execute)
43
+ .catch(() => ({
44
+ success: false,
45
+ writeUncertain: true,
46
+ error:
47
+ 'Task creation was interrupted. Verify the saved task before retrying.',
48
+ }));
49
+ pending.set(key, result);
50
+ const output = await result;
51
+ // Only an explicit pre-write failure is safe to retry. A transport failure
52
+ // may occur after insertion and must retain its recovery receipt.
53
+ if (
54
+ object(output) &&
55
+ output.created === false &&
56
+ output.writeUncertain !== true
57
+ )
58
+ pending.delete(key);
59
+ return output;
60
+ };
61
+ }