@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
@@ -4,13 +4,7 @@ import {
4
4
  normalizeWorkspaceId,
5
5
  verifyWorkspaceMembershipType,
6
6
  } from '@tuturuuu/utils/workspace-helper';
7
- import {
8
- consumeStream,
9
- type ModelMessage,
10
- smoothStream,
11
- stepCountIs,
12
- streamText,
13
- } from 'ai';
7
+ import { consumeStream, smoothStream, stepCountIs, streamText } from 'ai';
14
8
  import { type NextRequest, NextResponse } from 'next/server';
15
9
  import { normalizeStableModelId } from '../../credits/model-mapping';
16
10
  import {
@@ -18,8 +12,8 @@ import {
18
12
  resolvePlanModel,
19
13
  } from '../../credits/resolve-plan-model';
20
14
  import { withAiMemory } from '../../memory';
21
- import { createGoogleSearchToolSet } from '../../tools/google-search-tool';
22
15
  import type { CreditSource as SharedCreditSource } from '../credit-source';
16
+ import { shouldPresentWorkspaceArtifact } from '../mira-artifact-intent';
23
17
  import {
24
18
  shouldForceGoogleSearchForLatestUserMessage,
25
19
  shouldForceRenderUiForLatestUserMessage,
@@ -29,6 +23,7 @@ import {
29
23
  shouldUseParallelChecksForLatestUserMessage,
30
24
  } from '../mira-render-ui-policy';
31
25
  import { ChatRequestBodySchema, mapToUIMessages } from './chat-request-schema';
26
+ import { resolveChatTools } from './chat-tools';
32
27
  import { systemInstruction } from './default-system-instruction';
33
28
  import { prepareMiraToolStep } from './mira-step-preparation';
34
29
  import { resolveChatReasoningSettings } from './reasoning-settings';
@@ -50,9 +45,11 @@ import {
50
45
  import { performCreditPreflight } from './route-credits';
51
46
  import {
52
47
  extractLatestUserMessageContent,
48
+ mergeSystemInstructions,
53
49
  persistLatestUserMessage,
54
50
  persistRequestScopedUserMessage,
55
51
  prepareProcessedMessages,
52
+ splitSystemMessages,
56
53
  } from './route-message-preparation';
57
54
  import { prepareMiraRuntime } from './route-mira-runtime';
58
55
  import {
@@ -60,44 +57,6 @@ import {
60
57
  persistAssistantResponse,
61
58
  } from './stream-finish-persistence';
62
59
 
63
- function splitSystemMessages(messages: ModelMessage[]) {
64
- const systemMessages: string[] = [];
65
- const nonSystemMessages: ModelMessage[] = [];
66
-
67
- for (const message of messages) {
68
- if (message.role === 'system') {
69
- const systemMessage = getTextContent(message.content);
70
- if (systemMessage) systemMessages.push(systemMessage);
71
- continue;
72
- }
73
-
74
- nonSystemMessages.push(message);
75
- }
76
-
77
- return {
78
- messages: nonSystemMessages,
79
- system: systemMessages.join('\n\n').trim(),
80
- };
81
- }
82
-
83
- function getTextContent(content: ModelMessage['content']) {
84
- if (typeof content === 'string') return content.trim();
85
- if (!Array.isArray(content)) return '';
86
-
87
- return content
88
- .map((part) => (part.type === 'text' ? part.text.trim() : ''))
89
- .filter(Boolean)
90
- .join('\n')
91
- .trim();
92
- }
93
-
94
- function mergeSystemInstructions(...instructions: Array<string | null>) {
95
- return instructions
96
- .map((instruction) => instruction?.trim())
97
- .filter(Boolean)
98
- .join('\n\n');
99
- }
100
-
101
60
  export function createPOST(
102
61
  _options: {
103
62
  serverAPIKeyFallback?: boolean;
@@ -480,21 +439,22 @@ export function createPOST(
480
439
 
481
440
  const stepsRef: { current: unknown[] } = { current: [] };
482
441
 
483
- const { miraSystemPrompt, miraTools } = await prepareMiraRuntime({
484
- isMiraMode,
485
- wsId: normalizedWsId ?? undefined,
486
- workspaceContextId,
487
- creditWsId: billingWsId ?? normalizedWsId ?? undefined,
488
- request: req,
489
- user,
490
- userId: user.id,
491
- chatId,
492
- supabase,
493
- toolSupabase: sbAdmin as typeof supabase,
494
- timezone,
495
- taskBoardContext,
496
- getSteps: () => stepsRef.current,
497
- });
442
+ const { miraSystemPrompt, miraTools, prepareMiraDiscoveryStep } =
443
+ await prepareMiraRuntime({
444
+ isMiraMode,
445
+ wsId: normalizedWsId ?? undefined,
446
+ workspaceContextId,
447
+ creditWsId: billingWsId ?? normalizedWsId ?? undefined,
448
+ request: req,
449
+ user,
450
+ userId: user.id,
451
+ chatId,
452
+ supabase,
453
+ toolSupabase: sbAdmin as typeof supabase,
454
+ timezone,
455
+ taskBoardContext,
456
+ getSteps: () => stepsRef.current,
457
+ });
498
458
 
499
459
  const effectiveSource = isMiraMode ? 'Mira' : 'Rewise';
500
460
 
@@ -509,6 +469,9 @@ export function createPOST(
509
469
  });
510
470
 
511
471
  const reasoningSettings = resolveChatReasoningSettings(thinkingMode);
472
+ const forceWorkspaceArtifact = shouldPresentWorkspaceArtifact(
473
+ promptMessages.messages
474
+ );
512
475
  const forceRenderUi = shouldForceRenderUiForLatestUserMessage(
513
476
  promptMessages.messages
514
477
  );
@@ -529,14 +492,13 @@ export function createPOST(
529
492
  promptMessages.messages
530
493
  );
531
494
 
532
- const googleSearchTool = createGoogleSearchToolSet();
533
-
534
495
  type PrepareStep = NonNullable<
535
496
  NonNullable<Parameters<typeof streamText>[0]>['prepareStep']
536
497
  >;
537
- const prepareStep: PrepareStep = ({ steps }) => {
498
+ const prepareStep: PrepareStep = async ({ steps }) => {
538
499
  stepsRef.current = steps;
539
- return prepareMiraToolStep({
500
+ const policy = prepareMiraToolStep({
501
+ forceWorkspaceArtifact,
540
502
  steps,
541
503
  forceGoogleSearch,
542
504
  forceRenderUi,
@@ -545,6 +507,9 @@ export function createPOST(
545
507
  needsWorkspaceMembersTool,
546
508
  preferMarkdownTables,
547
509
  });
510
+ return prepareMiraDiscoveryStep
511
+ ? prepareMiraDiscoveryStep(policy)
512
+ : policy;
548
513
  };
549
514
 
550
515
  const { lease: claimedLease, response: leaseResponse } =
@@ -597,20 +562,16 @@ export function createPOST(
597
562
  promptMessages.system
598
563
  ),
599
564
  ...(cappedMaxOutput ? { maxOutputTokens: cappedMaxOutput } : {}),
565
+ tools: resolveChatTools(miraTools),
600
566
  ...(miraTools
601
567
  ? {
602
- tools: { ...miraTools, ...googleSearchTool } as NonNullable<
603
- Parameters<typeof streamText>[0]
604
- >['tools'],
605
568
  stopWhen: stepCountIs(25),
606
569
  toolChoice: 'auto' as const,
607
570
  prepareStep: prepareStep as NonNullable<
608
571
  NonNullable<Parameters<typeof streamText>[0]>['prepareStep']
609
572
  >,
610
573
  }
611
- : {
612
- tools: googleSearchTool,
613
- }),
574
+ : {}),
614
575
  providerOptions: {
615
576
  google: {
616
577
  thinkingConfig: {
@@ -1,6 +1,7 @@
1
- import { randomUUID } from 'node:crypto';
2
1
  import { deductAiCredits } from '@tuturuuu/ai/credits/check-credits';
3
2
  import type { CreditDeductionResult } from '../../credits/types';
3
+ import { collectAssistantMessageParts } from './assistant-message-parts';
4
+ import { countGoogleSearchQueries } from './google-search-usage';
4
5
 
5
6
  type UsageLike = {
6
7
  inputTokens?: number;
@@ -21,7 +22,6 @@ type UsageLike = {
21
22
  };
22
23
  reasoningTokens?: number;
23
24
  };
24
-
25
25
  type GroundingMetadataLike = {
26
26
  webSearchQueries?: string[];
27
27
  };
@@ -51,6 +51,7 @@ type SourceLike = {
51
51
  };
52
52
 
53
53
  type StepLike = {
54
+ content?: readonly unknown[];
54
55
  text?: string;
55
56
  toolCalls?: ReadonlyArray<ToolCallLike>;
56
57
  toolResults?: ReadonlyArray<ToolResultLike>;
@@ -248,105 +249,6 @@ function logGoogleSearchDebug(response: StreamFinishResponseLike): void {
248
249
  }
249
250
  }
250
251
 
251
- function countGoogleSearchQueries(
252
- response: StreamFinishResponseLike,
253
- allToolCalls: ToolCallLike[]
254
- ): number {
255
- const customGoogleSearchCalls = allToolCalls.filter(
256
- (toolCall) => toolCall.toolName === 'google_search'
257
- ).length;
258
- if (customGoogleSearchCalls > 0) return customGoogleSearchCalls;
259
-
260
- const topQueries =
261
- response.providerMetadata?.google?.groundingMetadata?.webSearchQueries;
262
- if (topQueries?.length) return topQueries.length;
263
-
264
- let perStepQueriesCount = 0;
265
- for (const step of response.steps ?? []) {
266
- const stepQueries =
267
- step.providerMetadata?.google?.groundingMetadata?.webSearchQueries;
268
- if (stepQueries?.length) {
269
- perStepQueriesCount += stepQueries.length;
270
- }
271
- }
272
- if (perStepQueriesCount > 0) return perStepQueriesCount;
273
-
274
- if (Array.isArray(response.sources) && response.sources.length > 0) {
275
- return 1;
276
- }
277
-
278
- return 0;
279
- }
280
-
281
- function collectUiMessageParts({
282
- allToolCalls,
283
- allToolResults,
284
- reasoningText,
285
- response,
286
- serializedSources,
287
- }: {
288
- allToolCalls: ToolCallLike[];
289
- allToolResults: ToolResultLike[];
290
- reasoningText: string;
291
- response: StreamFinishResponseLike;
292
- serializedSources: ReturnType<typeof collectSerializableSources>;
293
- }) {
294
- const parts: Record<string, unknown>[] = [];
295
-
296
- if (response.text) {
297
- parts.push({ type: 'text', text: response.text });
298
- }
299
-
300
- if (reasoningText) {
301
- parts.push({ type: 'reasoning', text: reasoningText });
302
- }
303
-
304
- for (const toolCall of allToolCalls) {
305
- const toolCallId =
306
- toolCall.toolCallId ?? readString(toolCall.toolCallId) ?? undefined;
307
- const matchingResult = allToolResults.find(
308
- (result) => toolCallId && readString(result.toolCallId) === toolCallId
309
- );
310
- parts.push({
311
- type: 'dynamic-tool',
312
- toolName: toolCall.toolName ?? matchingResult?.toolName ?? 'tool',
313
- toolCallId: toolCallId ?? randomUUID(),
314
- state: 'output-available',
315
- input: extractToolInput(toolCall),
316
- output: matchingResult ? extractToolOutput(matchingResult) : null,
317
- });
318
- }
319
-
320
- for (const toolResult of allToolResults) {
321
- const hasCall = allToolCalls.some(
322
- (toolCall) =>
323
- readString(toolCall.toolCallId) &&
324
- readString(toolCall.toolCallId) === readString(toolResult.toolCallId)
325
- );
326
- if (hasCall) continue;
327
-
328
- parts.push({
329
- type: 'dynamic-tool',
330
- toolName: toolResult.toolName ?? 'tool',
331
- toolCallId: toolResult.toolCallId ?? randomUUID(),
332
- state: 'output-available',
333
- output: extractToolOutput(toolResult),
334
- });
335
- }
336
-
337
- for (const source of serializedSources) {
338
- if (!source.url) continue;
339
- parts.push({
340
- type: 'source-url',
341
- sourceId: source.sourceId ?? source.url,
342
- title: source.title,
343
- url: source.url,
344
- });
345
- }
346
-
347
- return parts;
348
- }
349
-
350
252
  export function buildAbortedStreamFinishResponse(
351
253
  steps: ReadonlyArray<StepLike>
352
254
  ): StreamFinishResponseLike {
@@ -386,14 +288,6 @@ function sumStepUsage(steps: ReadonlyArray<StepLike>): UsageLike {
386
288
  );
387
289
  }
388
290
 
389
- function extractToolInput(toolCall: ToolCallLike) {
390
- return toolCall.input ?? toolCall.args ?? toolCall.arguments ?? {};
391
- }
392
-
393
- function extractToolOutput(toolResult: ToolResultLike) {
394
- return toolResult.output ?? toolResult.result ?? toolResult;
395
- }
396
-
397
291
  function readString(value: unknown) {
398
292
  return typeof value === 'string' && value.trim() ? value.trim() : null;
399
293
  }
@@ -419,7 +313,7 @@ function compactJsonValue(value: unknown, maxLength = 400): unknown {
419
313
  }
420
314
  }
421
315
 
422
- function compactAiMessagePart(part: Record<string, unknown>) {
316
+ function compactAiMessagePart(part: Record<string, unknown>, limit = 400) {
423
317
  const type = readString(part.type) ?? 'unknown';
424
318
 
425
319
  if (type === 'text' || type === 'reasoning') {
@@ -427,7 +321,7 @@ function compactAiMessagePart(part: Record<string, unknown>) {
427
321
  ...part,
428
322
  text:
429
323
  typeof part.text === 'string'
430
- ? truncateString(part.text, 4000)
324
+ ? truncateString(part.text, limit)
431
325
  : part.text,
432
326
  };
433
327
  }
@@ -438,11 +332,11 @@ function compactAiMessagePart(part: Record<string, unknown>) {
438
332
  toolName: part.toolName,
439
333
  toolCallId: part.toolCallId,
440
334
  state: part.state,
441
- input: compactJsonValue(part.input),
442
- output: compactJsonValue(part.output),
335
+ input: compactJsonValue(part.input, limit),
336
+ output: compactJsonValue(part.output, limit),
443
337
  errorText:
444
338
  typeof part.errorText === 'string'
445
- ? truncateString(part.errorText, 1200)
339
+ ? truncateString(part.errorText, limit)
446
340
  : part.errorText,
447
341
  };
448
342
  }
@@ -528,6 +422,22 @@ function compactAssistantMessageMetadata(
528
422
  metadata: ReturnType<typeof buildAssistantMessageMetadata>
529
423
  ) {
530
424
  const ai = metadata.ai;
425
+ let textOffset = 0;
426
+ const compactParts = ai.parts.map((part) => {
427
+ if (part.type === 'text' && typeof part.text === 'string') {
428
+ const result = {
429
+ type: 'text',
430
+ textStart: textOffset,
431
+ textLength: part.text.length,
432
+ };
433
+ textOffset += part.text.length + 2;
434
+ return result;
435
+ }
436
+ return compactAiMessagePart(
437
+ part,
438
+ Math.max(20, Math.min(400, Math.floor(1500 / ai.parts.length)))
439
+ );
440
+ });
531
441
  return {
532
442
  source: metadata.source,
533
443
  ...(metadata.requestId ? { requestId: metadata.requestId } : {}),
@@ -535,27 +445,11 @@ function compactAssistantMessageMetadata(
535
445
  finishReason: ai.finishReason,
536
446
  metadataCompacted: true,
537
447
  model: ai.model,
538
- observability: {
539
- contextBreakdown: Array.isArray(ai.observability.contextBreakdown)
540
- ? ai.observability.contextBreakdown.slice(-20)
541
- : [],
542
- },
543
- omittedPartCount: Math.max(0, ai.parts.length - 8),
544
- parts: ai.parts
545
- .slice(0, 8)
546
- .map((part) =>
547
- part && typeof part === 'object' && !Array.isArray(part)
548
- ? compactAiMessagePart(part as Record<string, unknown>)
549
- : part
550
- ),
448
+ observability: { contextBreakdown: [] },
449
+ omittedPartCount: 0,
450
+ parts: compactParts,
551
451
  usage: ai.usage,
552
452
  },
553
- ...(typeof metadata.reasoning === 'string'
554
- ? { reasoning: truncateString(metadata.reasoning, 3000) }
555
- : {}),
556
- ...(Array.isArray(metadata.sources)
557
- ? { sources: metadata.sources.slice(0, 20) }
558
- : {}),
559
453
  toolCallCount: Array.isArray(metadata.toolCalls)
560
454
  ? metadata.toolCalls.length
561
455
  : 0,
@@ -579,15 +473,6 @@ export async function persistAssistantResponse({
579
473
  const steps = response.steps ?? [];
580
474
  const { allToolCalls, allToolResults } = collectToolData(steps);
581
475
 
582
- if (
583
- !response.text &&
584
- allToolCalls.length === 0 &&
585
- allToolResults.length === 0
586
- ) {
587
- console.warn('onFinish: no text and no tool calls — skipping DB save');
588
- return false;
589
- }
590
-
591
476
  const reasoningText = collectReasoningText(response);
592
477
  const {
593
478
  cachedInputTokens,
@@ -597,13 +482,14 @@ export async function persistAssistantResponse({
597
482
  reasoningTokens,
598
483
  } = collectUsageTotals(response);
599
484
  const serializedSources = collectSerializableSources(response);
600
- const parts = collectUiMessageParts({
601
- allToolCalls,
602
- allToolResults,
603
- reasoningText,
604
- response,
605
- serializedSources,
485
+ const parts = collectAssistantMessageParts({
486
+ ...response,
487
+ sources: serializedSources,
606
488
  });
489
+ if (!parts.some((part) => part.type !== 'step-start')) {
490
+ console.warn('onFinish: no message parts — skipping DB save');
491
+ return false;
492
+ }
607
493
 
608
494
  const metadata = buildAssistantMessageMetadata({
609
495
  allToolCalls,
@@ -626,7 +512,10 @@ export async function persistAssistantResponse({
626
512
  const insertPayload = {
627
513
  chat_id: chatId,
628
514
  creator_id: userId,
629
- content: response.text || '',
515
+ content: parts
516
+ .filter((part) => part.type === 'text')
517
+ .map((part) => part.text)
518
+ .join('\n\n'),
630
519
  role: 'ASSISTANT',
631
520
  model: (model.includes('/')
632
521
  ? model.split('/').pop()!
@@ -666,7 +555,7 @@ export async function persistAssistantResponse({
666
555
  console.log('AI Response saved to database');
667
556
  logGoogleSearchDebug(response);
668
557
 
669
- const searchCount = countGoogleSearchQueries(response, allToolCalls);
558
+ const searchCount = countGoogleSearchQueries(model, response, allToolCalls);
670
559
  if (searchCount > 0) {
671
560
  console.log(
672
561
  `Google Search grounding detected: ${searchCount} search quer${searchCount === 1 ? 'y' : 'ies'}`
@@ -0,0 +1,38 @@
1
+ import type { ModelMessage } from 'ai';
2
+
3
+ /** Route explicit product presentation to native panels, including follow-ups. */
4
+ export function shouldPresentWorkspaceArtifact(
5
+ messages: ModelMessage[]
6
+ ): boolean {
7
+ const userTexts = messages
8
+ .filter((message) => message.role === 'user')
9
+ .map((message) =>
10
+ (typeof message.content === 'string'
11
+ ? message.content
12
+ : message.content
13
+ .filter((part) => part.type === 'text')
14
+ .map((part) => part.text)
15
+ .join(' ')
16
+ )
17
+ .normalize('NFD')
18
+ .replace(/\p{Diacritic}/gu, '')
19
+ .toLowerCase()
20
+ );
21
+ const latest = userTexts.at(-1) ?? '';
22
+ if (
23
+ /\b((?:don't|do not) (?:show|open|display|present|see|view)|(?:can't|cannot) (?:see|view)|without (?:(?:any|an?) )?(?:artifacts?|panels?)|(?:no|not)(?: (?:in|as))?(?: an?)? (?:artifacts?|panels?)|text[-\s]+only|chat[-\s]+only|how (do|can|to))\b|\bkhong (?:mo|hien thi|xem)\b/u.test(
24
+ latest
25
+ )
26
+ )
27
+ return false;
28
+ const product =
29
+ /\b(tasks?|to[-\s]?dos?|calendars?|schedules?|agendas?|finances?|wallets?|transactions?|meetings?|cong viec|lich|tai chinh|cuoc hop)\b/u;
30
+ const present =
31
+ /\b(show|display|present|open|view|see|hien thi|cho (toi|minh) xem|mo)\b/u;
32
+ if (present.test(latest) && product.test(latest)) return true;
33
+ return (
34
+ /\b(artifact|panel)\b/u.test(latest) &&
35
+ (present.test(latest) || /\b(should|instead)\b/u.test(latest)) &&
36
+ userTexts.slice(-4).some((text) => product.test(text))
37
+ );
38
+ }
@@ -0,0 +1,29 @@
1
+ import { buildMiraContext } from '../tools/context-builder';
2
+ import type { MiraWorkspaceContextState } from '../tools/workspace-context';
3
+ import { buildMiraSystemInstruction } from './mira-system-instruction';
4
+
5
+ /** The shared identity, memory, workspace and behavior prompt for Chat and Live. */
6
+ export async function buildMiraPrompt({
7
+ workspaceContext,
8
+ taskBoardInstruction,
9
+ ...options
10
+ }: Parameters<typeof buildMiraContext>[0] & {
11
+ workspaceContext: MiraWorkspaceContextState;
12
+ taskBoardInstruction?: string | null;
13
+ }): Promise<string> {
14
+ const { contextString, soul, isFirstInteraction } =
15
+ await buildMiraContext(options);
16
+ const workspaceInstruction = `## Workspace Context\n\nCurrent task/calendar/finance workspace context: ${workspaceContext.name} (${workspaceContext.personal ? 'personal' : 'shared'} workspace).\nUse this workspace for "my tasks", "my calendar", and "my finance" requests. Only switch to another workspace when the user explicitly names a different workspace.`;
17
+ return [
18
+ contextString,
19
+ workspaceInstruction,
20
+ taskBoardInstruction,
21
+ buildMiraSystemInstruction({
22
+ soul,
23
+ isFirstInteraction,
24
+ withoutPermission: options.withoutPermission,
25
+ }),
26
+ ]
27
+ .filter(Boolean)
28
+ .join('\n\n');
29
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ModelMessage } from 'ai';
2
+ import { isGoogleSearchToolName } from '../tools/google-search-events';
2
3
 
3
4
  type ToolStepLike = {
4
5
  toolCalls?: Array<{
@@ -229,6 +230,15 @@ function hasExplicitWorkspaceQualifier(text: string): boolean {
229
230
 
230
231
  const normalizedCandidate = normalizeWorkspaceQualifierCandidate(candidate);
231
232
  if (!normalizedCandidate) return false;
233
+ // Explicit workspace naming wins over presentation-destination heuristics.
234
+ if (/\bworkspace\b/u.test(normalizedCandidate)) return true;
235
+ // A presentation destination is not a named workspace.
236
+ if (
237
+ /^(?:(?:the|an?|my)\s+)?(?:(?:tasks?|calendar|finance|meetings?)\s+)?(?:panels?|artifacts?|chat|sidebar|views?|screen)\b/u.test(
238
+ normalizedCandidate
239
+ )
240
+ )
241
+ return false;
232
242
  if (DISALLOWED_WORKSPACE_QUALIFIERS.has(normalizedCandidate)) return false;
233
243
 
234
244
  return true;
@@ -386,6 +396,20 @@ export function shouldForceWorkspaceMembersForLatestUserMessage(
386
396
  return false;
387
397
  }
388
398
 
399
+ function selectedNames(output: Record<string, unknown> | undefined): string[] {
400
+ if (Array.isArray(output?.selectedTools)) {
401
+ return output.selectedTools.filter(
402
+ (name): name is string => typeof name === 'string'
403
+ );
404
+ }
405
+ if (Array.isArray(output?.tools)) {
406
+ return output.tools.flatMap((entry) =>
407
+ isRecord(entry) && typeof entry.name === 'string' ? [entry.name] : []
408
+ );
409
+ }
410
+ return [];
411
+ }
412
+
389
413
  export function extractSelectedToolsFromSteps(steps: unknown[]): string[] {
390
414
  for (let i = steps.length - 1; i >= 0; i--) {
391
415
  const step = steps[i] as ToolStepLike | undefined;
@@ -397,15 +421,10 @@ export function extractSelectedToolsFromSteps(steps: unknown[]): string[] {
397
421
  return tools.filter((tool): tool is string => typeof tool === 'string');
398
422
  }
399
423
 
400
- const selectResult = step?.toolResults?.find(
401
- (toolResult) => toolResult.toolName === 'select_tools'
402
- );
403
- const selectedTools = selectResult?.output?.selectedTools;
404
- if (Array.isArray(selectedTools)) {
405
- return selectedTools.filter(
406
- (tool): tool is string => typeof tool === 'string'
407
- );
408
- }
424
+ const selectResult =
425
+ step?.toolResults?.find((result) => result.toolName === 'select_tools') ??
426
+ step?.toolResults?.find((result) => result.toolName === 'search_tools');
427
+ if (selectResult) return selectedNames(selectResult.output);
409
428
  }
410
429
  return [];
411
430
  }
@@ -429,12 +448,12 @@ export function wasToolEverSelectedInSteps(
429
448
  if (fromCalls) return true;
430
449
 
431
450
  return (typedStep?.toolResults ?? []).some((toolResult) => {
432
- if (toolResult.toolName !== 'select_tools') return false;
433
- const selected = toolResult.output?.selectedTools;
434
- return (
435
- Array.isArray(selected) &&
436
- selected.some((tool) => typeof tool === 'string' && tool === toolName)
437
- );
451
+ if (
452
+ toolResult.toolName !== 'select_tools' &&
453
+ toolResult.toolName !== 'search_tools'
454
+ )
455
+ return false;
456
+ return selectedNames(toolResult.output).includes(toolName);
438
457
  });
439
458
  });
440
459
  }
@@ -443,13 +462,17 @@ export function hasToolCallInSteps(
443
462
  steps: unknown[],
444
463
  toolName: string
445
464
  ): boolean {
465
+ const matches = (name: string | undefined) =>
466
+ toolName === 'google_search'
467
+ ? isGoogleSearchToolName(name)
468
+ : name === toolName;
446
469
  return steps.some((step) => {
447
470
  const typedStep = step as ToolStepLike | undefined;
448
- const called = (typedStep?.toolCalls ?? []).some(
449
- (toolCall) => toolCall.toolName === toolName
471
+ const called = (typedStep?.toolCalls ?? []).some((toolCall) =>
472
+ matches(toolCall.toolName)
450
473
  );
451
- const hasResult = (typedStep?.toolResults ?? []).some(
452
- (toolResult) => toolResult.toolName === toolName
474
+ const hasResult = (typedStep?.toolResults ?? []).some((toolResult) =>
475
+ matches(toolResult.toolName)
453
476
  );
454
477
  return called || hasResult;
455
478
  });
@@ -516,7 +539,8 @@ export function countRenderUiAttemptsInSteps(steps: unknown[]): number {
516
539
  export function buildActiveToolsFromSelected(
517
540
  selectedTools: string[]
518
541
  ): string[] {
519
- if (selectedTools.length === 0) return ['select_tools', 'no_action_needed'];
542
+ if (selectedTools.length === 0)
543
+ return ['search_tools', 'select_tools', 'no_action_needed'];
520
544
 
521
545
  const unique = Array.from(new Set(selectedTools));
522
546
  const includesNoAction = unique.includes('no_action_needed');
@@ -526,6 +550,7 @@ export function buildActiveToolsFromSelected(
526
550
  (toolName) =>
527
551
  toolName !== 'select_tools' && toolName !== 'no_action_needed'
528
552
  ),
553
+ 'search_tools',
529
554
  'select_tools',
530
555
  ...(includesNoAction ? ['no_action_needed'] : []),
531
556
  ];
@@ -538,3 +563,23 @@ export function removeWorkspaceDiscoveryTools(selectedTools: string[]) {
538
563
  (toolName) => !WORKSPACE_DISCOVERY_TOOL_NAMES.has(toolName)
539
564
  );
540
565
  }
566
+
567
+ /** A dispatched UI command is complete only after an acknowledged success. */
568
+ export function getWorkspaceArtifactProgress(steps: unknown[]) {
569
+ let attempts = 0;
570
+ let completed = false;
571
+ for (const step of steps) {
572
+ const value = step as ToolStepLike | undefined;
573
+ const calls = (value?.toolCalls ?? []).filter(
574
+ (call) => call.toolName === 'show_workspace_artifact'
575
+ );
576
+ const results = (value?.toolResults ?? []).filter(
577
+ (result) => result.toolName === 'show_workspace_artifact'
578
+ );
579
+ attempts += Math.max(calls.length, results.length);
580
+ completed ||= results.some(
581
+ (result) => result.output?.success === true && !result.output.error
582
+ );
583
+ }
584
+ return { attempts, completed };
585
+ }