@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
@@ -8,11 +8,6 @@
8
8
 
9
9
  import type { PermissionId } from '@tuturuuu/types';
10
10
  import { DEV_MODE } from '@tuturuuu/utils/constants';
11
- import type { MiraToolName } from '../tools/mira-tools';
12
- import {
13
- MIRA_TOOL_DIRECTORY,
14
- MIRA_TOOL_PERMISSIONS,
15
- } from '../tools/mira-tools';
16
11
 
17
12
  export type MiraSoulConfig = {
18
13
  name?: string;
@@ -31,7 +26,6 @@ export function buildMiraSystemInstruction(opts?: {
31
26
  const soul = opts?.soul;
32
27
  const name = soul?.name || 'Mira';
33
28
  const isFirst = opts?.isFirstInteraction ?? false;
34
- const withoutPermission = opts?.withoutPermission;
35
29
 
36
30
  // ── Identity ──
37
31
  let identitySection = `You are ${name}, an AI personal assistant powered by Tuturuuu.`;
@@ -97,55 +91,9 @@ export function buildMiraSystemInstruction(opts?: {
97
91
  bootstrapSection = `\n\n## First Interaction\n\nThis is your first conversation with this user. Introduce yourself briefly as ${name}, mention what you can help with (tasks, calendar, finance, time tracking, memory), and ask one friendly question to get to know them. Keep it natural — don't list all features.`;
98
92
  }
99
93
 
100
- // ── Tool directory (lightweight listing for select_tools step) ──
101
- const directoryEntries = Object.entries(MIRA_TOOL_DIRECTORY) as Array<
102
- [MiraToolName, string]
103
- >;
104
-
105
- const toolDirectoryLines = directoryEntries
106
- .filter(([toolName]) => {
107
- if (!DEV_MODE && toolName === 'render_ui') {
108
- return false;
109
- }
110
- return true;
111
- })
112
- .map(([toolName, desc]) => {
113
- let statusStr = '';
114
- const requiredPerm = MIRA_TOOL_PERMISSIONS[toolName];
115
-
116
- if (requiredPerm && withoutPermission) {
117
- let isMissing = false;
118
- let missingStr = '';
119
-
120
- if (Array.isArray(requiredPerm)) {
121
- const missing = requiredPerm.filter((p) => withoutPermission(p));
122
- if (missing.length > 0) {
123
- isMissing = true;
124
- missingStr = missing.join(', ');
125
- }
126
- } else {
127
- if (withoutPermission(requiredPerm as PermissionId)) {
128
- isMissing = true;
129
- missingStr = requiredPerm as PermissionId;
130
- }
131
- }
132
-
133
- if (isMissing) {
134
- statusStr = ` (DISABLED: User lacks required permission(s) - ${missingStr})`;
135
- } else {
136
- statusStr = ` (Requires: ${Array.isArray(requiredPerm) ? requiredPerm.join(', ') : requiredPerm})`;
137
- }
138
- } else if (requiredPerm && !withoutPermission) {
139
- statusStr = ` (Requires: ${Array.isArray(requiredPerm) ? requiredPerm.join(', ') : requiredPerm})`;
140
- }
141
-
142
- return `- ${toolName}: ${desc}${statusStr}`;
143
- })
144
- .join('\n');
145
-
146
94
  return `## Fast-First Tool Selection and Caching
147
95
 
148
- When the user can benefit from immediate feedback, start with a brief streamed text acknowledgement or answer stub before calling tools. For short conversational or knowledge-only answers, answer directly without calling \`select_tools\` or \`no_action_needed\`. When tools are actually needed, call \`select_tools\` to pick which tools you need. The system caches this set: you can then call those tools as many times as needed without calling \`select_tools\` again. Only call \`select_tools\` again when you need to add or disable tools (e.g. you need a tool you didn't select, or want a smaller set for performance). **Exception**: if the user message contains profile, preference, identity, behavioral, or configuration information that should be saved, or asks for real-time/external web information, this is NOT pure conversation.
96
+ When helpful and tool policy permits, stream a brief acknowledgement before calling tools. After tools have run, report results directly; never add a retrospective "Let me check" preamble. For short conversational or knowledge-only answers, answer directly without calling \`select_tools\` or \`no_action_needed\`. When tools are needed, use \`search_tools\` with the operation you want; it discovers and activates a small working set. Use \`select_tools\` only when you already know the exact names. The system caches this set: you can then call those tools as many times as needed without calling \`select_tools\` again. Only call \`select_tools\` again when you need to add or disable tools (e.g. you need a tool you didn't select, or want a smaller set for performance). **Exception**: if the user message contains profile, preference, identity, behavioral, or configuration information that should be saved, or asks for real-time/external web information, this is NOT pure conversation.
149
97
 
150
98
  You MUST call the actual tool function for ANY action. Saying "I've done it" without a tool call is LYING. The user sees tool call indicators.
151
99
 
@@ -171,45 +119,13 @@ ${identitySection} You help users manage their productivity — tasks, calendar,
171
119
  - Do not stop after the first tool failure if it looks recoverable. When validation fails or IDs/inputs look wrong, use discovery tools to fetch the exact values, then retry once with corrected arguments.
172
120
  - If you get **3 consecutive tool failures** for the same intent, or the failure is clearly not recoverable, stop retrying. Report what failed, which tool(s) were used, and what input or permission the user should check.
173
121
 
174
- ## Available Tools
175
-
176
- Below is the complete list of tools you can select via \`select_tools\`. Choose only the tools you need for the current request:
122
+ ## On-demand Capabilities
177
123
 
178
- ${toolDirectoryLines}
124
+ Use \`search_tools\` to discover operations by their names and descriptions. Available domains include task/board/list/label/project/assignee CRUD, calendar connections/sync/events/scheduling, finance wallets/transactions/categories/tags, time tracking, memory/settings, workspace UI, files/images, and research. Search for the specific operation, then wait for the next model step before calling a discovered tool. Results contain up to five matching names and descriptions. If nothing matches, retry with concrete capability keywords. Tool discovery performs no product action and never approves a write; the full catalog is not loaded into every turn. Reuse the working set until the operation changes. Select at most 12 tools at a time.
179
125
 
180
- ## Tool Selection Strategy
126
+ For requests to show, view, open, or present tasks, calendar, finance, or meetings, call \`show_workspace_artifact\` with the matching kind. A markdown table or task-data read is not an artifact. For "show it in an artifact" follow-ups, infer the kind from the recent conversation. Native panels fetch their own data; do not fetch full task lists merely to open one. Never claim an artifact is visible without a successful opening tool result.
181
127
 
182
- When tools are needed, call \`select_tools\` once; the chosen set is cached. Reuse it (e.g. multiple \`recall\` calls) without calling \`select_tools\` again. Call \`select_tools\` again only when you need to add or remove tools. When calling \`select_tools\`, pick ALL tools you expect to need for the request. Always include discovery tools when you need IDs. For example:
183
- - "Show my tasks and upcoming events" → \`["get_my_tasks", "get_upcoming_events"]\`
184
- ${
185
- DEV_MODE
186
- ? `- "Summarize my day" → \`["get_my_tasks", "render_ui"]\` (Use UI for beautiful summaries)`
187
- : `- "Summarize my day" → \`["get_my_tasks"]\``
188
- }
189
- - "Create a task and assign it to someone" → \`["list_boards", "list_task_lists", "create_task", "list_workspace_members", "add_task_assignee"]\`
190
- - "What's my spending this month?" → \`["get_spending_summary"]\`
191
- ${
192
- DEV_MODE
193
- ? `- "Show my time tracking stats this month" → \`["render_ui"]\` (Render \`TimeTrackingStats\` component)`
194
- : `- "Show my time tracking stats this month" → \`["get_time_tracker_stats"]\``
195
- }
196
- - "I spent 50k on food" → \`["list_wallets", "log_transaction"]\` (ALWAYS discover wallets first)
197
- - "What's the weather today?" → \`["google_search"]\` (Real-time info needs web search)
198
- - "Latest news about AI" → \`["google_search"]\` (Search + concise markdown summary with sources)
199
- - "Verify this plan deeply and check assumptions" → \`["run_parallel_checks"]\` (Use parallel subagents for deeper verification while keeping the main assistant fast)
200
- - "Analyze this attached .xlsx/.pptx/.docx file" → \`["convert_file_to_markdown"]\` (Convert attachment to markdown first)
201
- - "Summarize this YouTube link" → \`["no_action_needed"]\` (Google/Gemini receives the YouTube URL as native video input. Answer directly from the video input; do NOT use \`convert_file_to_markdown\` or \`google_search\` for YouTube video summaries.)
202
- - "Create a QR code for this URL: https://example.com" → \`["create_qr_code"]\`
203
- - "Show me a table of useful content" → \`["no_action_needed"]\` (Respond directly with a native markdown table)
204
- - "What workspace are you using for my tasks?" → \`["get_workspace_context"]\`
205
- - "Show my tasks from Acme Workspace" → \`["list_accessible_workspaces", "set_workspace_context", "get_my_tasks"]\`
206
- - "What's my tasks in Tuturuuu" → \`["list_accessible_workspaces", "set_workspace_context", "get_my_tasks"]\`
207
- - "Who's in my workspace?" → \`["get_workspace_context", "list_workspace_members"]\`
208
- - "Who's in Tuturuuu workspace?" → \`["list_accessible_workspaces", "set_workspace_context", "list_workspace_members"]\`
209
- - "Hi, how are you?" → \`["no_action_needed"]\`
210
- - "Remember that my favorite color is blue" → \`["remember"]\` (with \`category: "preference"\`)
211
- - "Use the profile/preferences docs I shared in this chat going forward" → \`["update_my_settings", "remember"]\` (persist behavior + long-term context, do NOT use \`no_action_needed\`)
212
- - "Change my meeting with Quoc to 5pm" → \`["get_upcoming_events", "update_event"]\` (Be autonomous: ALWAYS fetch events and update directly. Do NOT ask for permission to update or delete unless the request is dangerously ambiguous.)
128
+ A successful read or UI action is complete. Do not reselect and repeat it to verify. Product mutations return their saved IDs and destination; use those results for follow-up questions. A runtime guard may disable tools after repeated completed calls or repeated failures; then answer from the available evidence and explain any incomplete work.
213
129
 
214
130
  ## Rich Content Rendering
215
131
 
@@ -329,9 +245,9 @@ ${
329
245
 
330
246
  ### Special components
331
247
  - **MyTasks**: Renders the complete "My Tasks" interface (summary, filters, and list).
332
- - Use for user-facing task summaries and interactive displays (e.g., showing pending items, current workload).
248
+ - Use only inside an explicitly requested custom composed UI that combines task summaries with other components.
333
249
  - \`props\`: \`showSummary\` (boolean), \`showFilters\` (boolean).
334
- - **vs. get_my_tasks tool**: Prefer MyTasks for rendering task UI; use get_my_tasks when the agent needs raw task data for filtering, processing, or logic before rendering.
250
+ - **vs. get_my_tasks tool**: Prefer the native tasks workspace artifact for product task views; use MyTasks only inside a specifically requested custom composed UI; use get_my_tasks when the agent needs raw task data for filtering, processing, or logic before rendering.
335
251
  - **TimeTrackingStats**: Renders a standardized time-tracking stats dashboard and fetches period data internally.
336
252
  - \`props\`:
337
253
  - \`period\` (today|this_week|this_month|last_7_days|last_30_days|custom) — REQUIRED
@@ -386,86 +302,6 @@ ${
386
302
  `
387
303
  : ''
388
304
  }
389
- ## Tool Domain Details
390
-
391
- ### Tasks
392
- Get, create, update, complete, and delete tasks. Manage boards, lists, labels, projects, and assignees. Tasks live in boards → lists hierarchy. Use \`list_boards\` and \`list_task_lists\` to discover structure.
393
- - **CRITICAL: Never create orphaned tasks.** Every task MUST belong to a task list. Before creating tasks, ALWAYS call \`list_boards\` to discover boards, then \`list_task_lists\` to discover lists within the chosen board, and pass the \`boardId\` and \`listId\` to \`create_task\`. If the user specifies a board or list name, match it. If no boards/lists exist yet, \`create_task\` will auto-create defaults — but when structure already exists, you MUST respect it.
394
- - **Task creation flow**: \`list_boards\` → \`list_task_lists(boardId)\` → \`create_task(name, boardId, listId, ...)\`. NEVER skip discovery when boards/lists already exist.
395
- - **Filtering tasks**: Use \`get_my_tasks\` with **category** (values: \`all\`, \`overdue\`, \`today\`, \`upcoming\`) to filter by time.
396
- - **Updating due date**: Use \`update_task\` with **taskId** (task UUID) and **endDate** (ISO date string, e.g. \`2026-03-01\` or \`2026-03-01T23:59:59\` for end of day).
397
-
398
- ### Calendar
399
- View and create events. Events support end-to-end encryption (E2EE). Use \`check_e2ee_status\` to verify encryption and \`enable_e2ee\` to turn it on. Events are automatically encrypted/decrypted when E2EE is active.
400
-
401
- ### Finance
402
- Full CRUD for wallets, transactions, categories, and tags. Use \`log_transaction\` for quick logging, or the specific CRUD tools for management. Positive amounts = income, negative = expense.
403
-
404
-
405
- **Autonomous resource discovery (IMPORTANT):** When the user asks to log a transaction, you MUST first call \`list_wallets\` to discover available wallet IDs — NEVER guess or fabricate a wallet ID. If no wallets exist, create one with \`create_wallet\` before logging. Similarly, use \`list_transaction_categories\` to find categories when needed. Be proactive: discover → act → summarize, without asking the user for IDs they don't know.
406
- ${
407
- DEV_MODE
408
- ? `
409
- **Transaction Forms (IMPORTANT):** When rendering a transaction form via \`render_ui\`, do NOT include a radio button or input for "Transaction Type" (Income/Expense). The system automatically infers the type based on the selected category. Just provide the category selection. Always provide a \`Metric\` or \`Progress\` bar alongside the form to show current financial status.`
410
- : ''
411
- }
412
-
413
- ### Time Tracking
414
- Start and stop work session timers. Starting a new timer automatically stops any running one.
415
- Use \`get_time_tracker_stats\` and \`get_time_tracker_goals\` when users ask for productivity insights, progress against goals, or streak-based summaries.
416
- For deterministic summaries: call \`get_time_tracker_stats\` first, then call \`get_time_tracker_goals\` with \`includeProgress: true\`.
417
- When users ask to add, edit, pause/resume, or remove time-tracker goals, use \`create_time_tracker_goal\`, \`update_time_tracker_goal\`, and \`delete_time_tracker_goal\`.
418
- If any tool call requires/references a category name (not ID), call \`list_time_tracking_categories\` first to resolve the correct category ID.
419
-
420
- ### Memory
421
- Save and recall facts, preferences, and personal details.
422
- - **Proactive saving**: Actively remember information that fosters our long-term conversation and relationship, and contributes to the continuity and depth of our interactions. Don't wait for the user to say "remember...". If they mention a hobby, a project, or a related fact, log it immediately with \`remember\`.
423
- - **Identity/Profile Inputs**: If users provide personal profile details or instruction documents, persist durable user facts with \`remember\` during that same turn.
424
- - **REQUIRED CATEGORY**: You MUST always provide a valid \`category\` when calling \`remember\`. Valid categories are ONLY: \`preference\`, \`fact\`, \`conversation_topic\`, \`event\`, \`person\`. Omitting \`category\` will cause a validation error!
425
- - **Proactive recall**: At the start of actionable requests, USE \`recall\` to fetch relevant context so you can provide personalized responses.
426
- - **Hygiene & Maintenance**: Periodically USE \`list_memories\` to review what you know. USE \`merge_memories\` to consolidate duplicates. USE \`delete_memory\` to remove outdated entries.
427
- - **Context Limit**: You only see the **last 10 messages** of the chat to save tokens. You MUST rely on your long-term memory to maintain context. If you forget something, \`recall\` it.
428
- - **Store rich values**: Don't split related facts. One entry per person with all details.
429
- - **Recall efficiently**: For "everything you know about me", use \`query: null, maxResults: 50\`.
430
-
431
- ### Images
432
- Generate images from text descriptions via \`create_image\`. Only for visual/artistic content — NOT for equations, code, charts.
433
-
434
- ### QR Codes
435
- Generate QR codes from any text via \`create_qr_code\`. This tool supports custom foreground/background colors and output size, and stores the generated PNG in workspace Drive storage.
436
- Only call \`create_qr_code\` when the current user message contains the exact text or URL to encode, or clearly refers to one immediately preceding value. If the user only says "create QR", "make a QR code", or similar without a value, ask for the text or URL. Never concatenate unrelated earlier chat text into the QR payload.
437
-
438
- ### File Conversion (MarkItDown)
439
- - Use \`convert_file_to_markdown\` when the user asks to read/analyze attached binary documents such as Excel, Word, PowerPoint, PDF, etc.
440
- - If the file is already attached in the current chat, prefer passing \`fileName\` (or omit arguments to convert the latest attachment).
441
- - Use this tool only when file conversion is actually needed for the user's request.
442
- - Do NOT use MarkItDown for YouTube links. Google/Gemini models receive public or unlisted YouTube URLs as native video input and can summarize or answer from that video directly. If video access fails, explain the model could not access the video.
443
-
444
- ### Self-Configuration
445
- Update YOUR personality via \`update_my_settings\`. The \`name\` field is YOUR name (the assistant). If the user says "call me X", use \`remember\` (and \`update_user_name\` if they want their account display name changed). Proactively use \`update_my_settings\` when users describe assistant behavior preferences ("be more casual", "keep it short") or provide identity/config documents.
446
-
447
- ### Appearance
448
- Use \`set_theme\` to switch the UI between dark mode, light mode, or system default. Use \`set_immersive_mode\` to enter or exit immersive fullscreen mode for the chat. Act immediately when the user asks — no confirmation needed.
449
-
450
- ### Web Search (\`google_search\`)
451
- \`google_search\` lets you search the web for current, real-time information. Use it whenever the user asks about:
452
- - Current events, news, weather, sports scores
453
- - Product prices, availability, business hours
454
- - Facts that may have changed since your training data
455
- - Any question where up-to-date information would improve your answer
456
-
457
- **IMPORTANT**: \`google_search\` is always available in Mira mode. Call it directly whenever web grounding is needed. You may include it in \`select_tools\` for planning clarity.
458
-
459
- **Tool name rule**: Use the tool name \`google_search\` for web lookup. Do NOT call a generic \`search\` tool name in assistant text or planning.
460
-
461
- **Usage**: If the user asks for latest/current information (news, pricing, weather, trending updates), invoke \`google_search\` before answering.
462
-
463
- ### User
464
- Use \`update_user_name\` to update the user's display name or full name when they ask you to change how they are addressed. You MUST provide at least one field (\`displayName\` or \`fullName\`).
465
-
466
- ### Workspace
467
- Use \`list_workspace_members\` to see who is in the current workspace context and to find user IDs for task assignment. If the user names a different workspace, resolve it with \`list_accessible_workspaces\` and \`set_workspace_context\` first.
468
-
469
305
  ## Boundaries
470
306
 
471
307
  - You can write and display code, but you cannot execute it.
@@ -476,7 +312,7 @@ Use \`list_workspace_members\` to see who is in the current workspace context an
476
312
 
477
313
  ## FINAL REMINDER — Cache Tools, Re-select Only When Needed
478
314
 
479
- Per user message: (1) stream brief text first when helpful, (2) call \`select_tools\` to set your tool set when tools are needed, (3) use those tools as needed (reuse the cache — no need to call \`select_tools\` before each tool call), (4) call \`select_tools\` again only to add/disable tools, (5) summarize results in natural language.
315
+ Per user message: (1) stream brief text first when helpful, (2) discover needed capabilities with \`search_tools\` or activate known names with \`select_tools\`, (3) use those tools as needed (reuse the cache — no need to call \`select_tools\` before each tool call), (4) call \`select_tools\` again only to add/disable tools, (5) summarize results in natural language.
480
316
  `;
481
317
  }
482
318
 
@@ -0,0 +1,84 @@
1
+ import { isGoogleSearchToolName } from '../tools/google-search-events';
2
+
3
+ type Call = {
4
+ toolName?: string;
5
+ toolCallId?: string;
6
+ input?: unknown;
7
+ args?: unknown;
8
+ output?: unknown;
9
+ };
10
+ type Step = { toolCalls?: Call[]; toolResults?: Call[] };
11
+ const isObject = (value: unknown): value is Record<string, unknown> =>
12
+ !!value && typeof value === 'object' && !Array.isArray(value);
13
+
14
+ function stableValue(value: unknown): unknown {
15
+ if (Array.isArray(value)) return value.map(stableValue);
16
+ if (!isObject(value)) return value;
17
+ return Object.fromEntries(
18
+ Object.keys(value)
19
+ .sort()
20
+ .map((key) => [key, stableValue(value[key])])
21
+ );
22
+ }
23
+
24
+ function isReadOrUi(name: string) {
25
+ return (
26
+ isGoogleSearchToolName(name) ||
27
+ /^(get_|list_|search_|recall$|check_|preview_)/.test(name) ||
28
+ [
29
+ 'select_tools',
30
+ 'show_workspace_artifact',
31
+ 'manage_workspace',
32
+ 'set_sidebar',
33
+ 'set_theme',
34
+ 'set_immersive_mode',
35
+ 'run_parallel_checks',
36
+ 'no_action_needed',
37
+ ].includes(name)
38
+ );
39
+ }
40
+
41
+ /** Stop repeated discovery/read/UI cycles, while writes invalidate cached reads. */
42
+ export function getMiraToolLoopReason(steps: unknown[]): string | null {
43
+ const successes = new Map<string, number>();
44
+ const failures = new Map<string, number>();
45
+ for (const value of steps) {
46
+ if (!isObject(value)) continue;
47
+ const step = value as Step;
48
+ for (const result of step.toolResults ?? []) {
49
+ const name = result.toolName;
50
+ if (!name) continue;
51
+ const call = step.toolCalls?.find((candidate) =>
52
+ result.toolCallId
53
+ ? candidate.toolCallId === result.toolCallId
54
+ : candidate.toolName === name
55
+ );
56
+ const input = result.input ?? call?.input ?? call?.args ?? {};
57
+ const signature = `${name}:${JSON.stringify(stableValue(input))}`;
58
+ const output = result.output;
59
+ if (isObject(output) && output.reusedResult === true)
60
+ return `Repeated mutation prevented: ${name}`;
61
+ const failed =
62
+ isObject(output) &&
63
+ (output.error ||
64
+ output.ok === false ||
65
+ output.success === false ||
66
+ output.partialFailure === true);
67
+ if (failed) {
68
+ const count = (failures.get(signature) ?? 0) + 1;
69
+ failures.set(signature, count);
70
+ if (count >= 3) return `Repeated failure: ${name}`;
71
+ } else if (isReadOrUi(name)) {
72
+ failures.delete(signature);
73
+ const count = (successes.get(signature) ?? 0) + 1;
74
+ successes.set(signature, count);
75
+ if (count >= 2) return `Repeated completed action: ${name}`;
76
+ } else {
77
+ // A successful product mutation can make previous reads stale.
78
+ successes.clear();
79
+ failures.clear();
80
+ }
81
+ }
82
+ }
83
+ return null;
84
+ }
@@ -1,10 +1,11 @@
1
1
  import type { SupabaseClient } from '@tuturuuu/supabase';
2
+ import { AI_REQUEST_MAX_OUTPUT_TOKENS } from './constants';
2
3
  import { resolveGatewayModelId } from './model-mapping';
3
4
 
4
5
  /**
5
6
  * Query the gateway model's output price per token from the database.
6
7
  * Returns the flat output_price_per_token. For tiered pricing, returns
7
- * the lowest tier cost as a conservative estimate.
8
+ * the highest valid tier cost as a conservative upper bound.
8
9
  */
9
10
  async function getOutputPricePerToken(
10
11
  sbAdmin: SupabaseClient,
@@ -23,16 +24,21 @@ async function getOutputPricePerToken(
23
24
 
24
25
  if (error || !data) return null;
25
26
 
26
- // If tiered pricing exists, use the first (lowest) tier cost
27
+ // Without input-tier context, budget against the most expensive output tier.
27
28
  if (
28
29
  data.output_tiers &&
29
30
  Array.isArray(data.output_tiers) &&
30
31
  data.output_tiers.length > 0
31
32
  ) {
32
- const firstTier = data.output_tiers[0] as { cost?: string };
33
- if (firstTier?.cost) {
34
- return parseFloat(firstTier.cost);
35
- }
33
+ const costs = data.output_tiers.map((tier) => {
34
+ const cost = (tier as { cost?: unknown } | null)?.cost;
35
+ return typeof cost === 'number' ||
36
+ (typeof cost === 'string' && cost.trim() !== '')
37
+ ? Number(cost)
38
+ : NaN;
39
+ });
40
+ if (costs.some((cost) => !Number.isFinite(cost) || cost <= 0)) return null;
41
+ return Math.max(...costs);
36
42
  }
37
43
 
38
44
  return data.output_price_per_token ?? null;
@@ -55,12 +61,27 @@ export async function capMaxOutputTokensByCredits(
55
61
  remainingCredits: number,
56
62
  markupMultiplier = 1.0
57
63
  ): Promise<number | null> {
58
- if (remainingCredits <= 0) return null;
64
+ if (
65
+ !Number.isFinite(remainingCredits) ||
66
+ remainingCredits <= 0 ||
67
+ !Number.isFinite(markupMultiplier) ||
68
+ markupMultiplier < 1
69
+ )
70
+ return null;
71
+ if (
72
+ maxOutputTokens !== null &&
73
+ (!Number.isSafeInteger(maxOutputTokens) || maxOutputTokens <= 0)
74
+ )
75
+ return null;
59
76
 
60
77
  const outputPricePerToken = await getOutputPricePerToken(sbAdmin, modelId);
61
- if (!outputPricePerToken || outputPricePerToken <= 0) {
62
- // Can't determine pricing — fall through to DB-layer cap
63
- return maxOutputTokens;
78
+ if (
79
+ !outputPricePerToken ||
80
+ !Number.isFinite(outputPricePerToken) ||
81
+ outputPricePerToken <= 0
82
+ ) {
83
+ // Missing or invalid prices must not authorize a provider expense.
84
+ return null;
64
85
  }
65
86
 
66
87
  // credits = (tokens * pricePerToken / 0.0001) * markup
@@ -69,9 +90,12 @@ export async function capMaxOutputTokensByCredits(
69
90
  (remainingCredits * 0.0001) / markupMultiplier / outputPricePerToken
70
91
  );
71
92
 
72
- if (affordableTokens < 1) return null;
73
- if (!maxOutputTokens) return affordableTokens;
74
- return Math.min(maxOutputTokens, affordableTokens);
93
+ if (!Number.isFinite(affordableTokens) || affordableTokens < 1) return null;
94
+ return Math.min(
95
+ maxOutputTokens ?? AI_REQUEST_MAX_OUTPUT_TOKENS,
96
+ affordableTokens,
97
+ AI_REQUEST_MAX_OUTPUT_TOKENS
98
+ );
75
99
  }
76
100
 
77
101
  /**
@@ -83,8 +107,17 @@ export function computeAffordableTokens(
83
107
  outputPricePerToken: number,
84
108
  markupMultiplier = 1.0
85
109
  ): number {
86
- if (remainingCredits <= 0 || outputPricePerToken <= 0) return 0;
87
- return Math.floor(
110
+ if (
111
+ ![remainingCredits, outputPricePerToken, markupMultiplier].every(
112
+ Number.isFinite
113
+ ) ||
114
+ remainingCredits <= 0 ||
115
+ outputPricePerToken <= 0 ||
116
+ markupMultiplier < 1
117
+ )
118
+ return 0;
119
+ const affordableTokens = Math.floor(
88
120
  (remainingCredits * 0.0001) / markupMultiplier / outputPricePerToken
89
121
  );
122
+ return Number.isFinite(affordableTokens) ? affordableTokens : 0;
90
123
  }
@@ -73,7 +73,18 @@ export async function checkAiCredits(
73
73
  }
74
74
 
75
75
  const row = Array.isArray(data) ? data[0] : data;
76
- if (!row) {
76
+ const rawBalance: unknown = row?.remaining_credits;
77
+ const balance =
78
+ typeof rawBalance === 'number' ||
79
+ (typeof rawBalance === 'string' && rawBalance.trim() !== '')
80
+ ? Number(rawBalance)
81
+ : NaN;
82
+ if (
83
+ !row ||
84
+ typeof row.allowed !== 'boolean' ||
85
+ !Number.isFinite(balance) ||
86
+ balance < 0
87
+ ) {
77
88
  return {
78
89
  allowed: false,
79
90
  remainingCredits: 0,
@@ -85,8 +96,8 @@ export async function checkAiCredits(
85
96
  }
86
97
 
87
98
  return {
88
- allowed: row.allowed ?? true,
89
- remainingCredits: Number(row.remaining_credits ?? 0),
99
+ allowed: row.allowed === true && balance > 0,
100
+ remainingCredits: balance,
90
101
  tier: row.tier ?? 'FREE',
91
102
  maxOutputTokens: row.max_output_tokens ?? null,
92
103
  errorCode: (row.error_code as CreditErrorCode) ?? null,
@@ -29,3 +29,6 @@ export const CREDIT_ERROR_CODES = {
29
29
 
30
30
  export type CreditErrorCode =
31
31
  (typeof CREDIT_ERROR_CODES)[keyof typeof CREDIT_ERROR_CODES];
32
+
33
+ /** Technical ceiling, separate from the purchased credit allowance. */
34
+ export const AI_REQUEST_MAX_OUTPUT_TOKENS = 65_536;
@@ -5,6 +5,7 @@ export {
5
5
  export { checkAiCredits, deductAiCredits } from './check-credits';
6
6
  export {
7
7
  AI_FEATURES,
8
+ AI_REQUEST_MAX_OUTPUT_TOKENS,
8
9
  type AiFeature,
9
10
  CREDIT_ERROR_CODES,
10
11
  CREDIT_UNIT_USD,
@@ -232,10 +232,13 @@ export function createPOST(
232
232
  creditCheck.maxOutputTokens,
233
233
  creditCheck.remainingCredits
234
234
  );
235
- if (cappedMaxOutput === null && creditCheck.remainingCredits <= 0) {
235
+ if (cappedMaxOutput === null) {
236
236
  return NextResponse.json(
237
- { error: 'AI credits insufficient', code: 'CREDITS_EXHAUSTED' },
238
- { status: 403 }
237
+ {
238
+ error: 'AI request budget could not be verified',
239
+ code: 'CREDIT_CHECK_FAILED',
240
+ },
241
+ { status: 503 }
239
242
  );
240
243
  }
241
244
 
@@ -0,0 +1,8 @@
1
+ /** Keep database reservations at one per ten-second upload window. */
2
+ export const MEET_AUDIO_BATCH_INTERVAL_MS = 10_000;
3
+ export const MEET_AUDIO_BATCH_MAX_CLIPS = 48;
4
+ // Base64 plus prompts stays below the provider's 20 MB inline-audio request bound.
5
+ export const MEET_AUDIO_BATCH_MAX_BYTES = 12_000_000;
6
+ export const MEET_AUDIO_REQUEST_MAX_BYTES = 12_100_000;
7
+ export const MEET_AUDIO_PENDING_MAX_BYTES = 48_000_000;
8
+ export const MEET_AUDIO_SESSION_MAX_BATCHES = 1080;
@@ -0,0 +1,25 @@
1
+ import { z } from 'zod';
2
+ import { MEET_AUDIO_BATCH_MAX_CLIPS } from './audio-contract';
3
+
4
+ export const audioTranscriptsSchema = z.object({
5
+ transcripts: z.array(
6
+ z.object({
7
+ index: z
8
+ .number()
9
+ .int()
10
+ .min(0)
11
+ .max(MEET_AUDIO_BATCH_MAX_CLIPS - 1),
12
+ text: z.string(),
13
+ })
14
+ ),
15
+ });
16
+ export function orderedAudioTranscripts(output: unknown, count: number) {
17
+ const { transcripts } = audioTranscriptsSchema.parse(output);
18
+ if (
19
+ transcripts.length !== count ||
20
+ new Set(transcripts.map((part) => part.index)).size !== count ||
21
+ transcripts.some((part) => part.index >= count)
22
+ )
23
+ throw new Error('Incomplete audio source transcription');
24
+ return transcripts.sort((a, b) => a.index - b.index).map((part) => part.text);
25
+ }
@@ -46,6 +46,7 @@ export function formatMeetSourceAnswer(
46
46
  text: string,
47
47
  sources: MeetCitationSource[]
48
48
  ) {
49
+ if (!text.trim()) return '';
49
50
  const selected = selectMeetSources(sources);
50
51
  const footer = [
51
52
  ...new Map(selected.map((source) => [source.url, source])).values(),
@@ -159,7 +159,10 @@ export async function answerMeetChat(
159
159
  messages: [...initialMessages, ...result.responseMessages],
160
160
  text: search.unavailable()
161
161
  ? 'Google did not return verified sources for this question. Please try again or ask a more specific public question.'
162
- : formatMeetSourceAnswer(result.text, sources),
162
+ : formatMeetSourceAnswer(
163
+ result.text.trim() || (!privateResult ? search.answer() : ''),
164
+ sources
165
+ ),
163
166
  ...measureMeetGeneration(
164
167
  [
165
168
  ...result.steps.map((step) => ({