@tuturuuu/ai 0.0.10

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 (130) hide show
  1. package/README.md +76 -0
  2. package/package.json +106 -0
  3. package/src/api-key-hash.ts +28 -0
  4. package/src/calendar/events.ts +34 -0
  5. package/src/calendar/route.ts +114 -0
  6. package/src/chat/credit-source.ts +1 -0
  7. package/src/chat/google/chat-request-schema.ts +150 -0
  8. package/src/chat/google/default-system-instruction.ts +198 -0
  9. package/src/chat/google/message-file-processing.ts +212 -0
  10. package/src/chat/google/mira-step-preparation.ts +221 -0
  11. package/src/chat/google/new/route.ts +368 -0
  12. package/src/chat/google/route-auth.ts +81 -0
  13. package/src/chat/google/route-chat-resolution.ts +98 -0
  14. package/src/chat/google/route-credits.ts +61 -0
  15. package/src/chat/google/route-message-preparation.ts +331 -0
  16. package/src/chat/google/route-mira-runtime.ts +206 -0
  17. package/src/chat/google/route.ts +632 -0
  18. package/src/chat/google/stream-finish-persistence.ts +722 -0
  19. package/src/chat/google/summary/route.ts +153 -0
  20. package/src/chat/mira-render-ui-policy.ts +540 -0
  21. package/src/chat/mira-system-instruction.ts +484 -0
  22. package/src/chat-sdk/adapters.ts +389 -0
  23. package/src/chat-sdk/registry.ts +197 -0
  24. package/src/chat-sdk.ts +33 -0
  25. package/src/core.ts +3 -0
  26. package/src/credits/cap-output-tokens.ts +90 -0
  27. package/src/credits/check-credits.ts +232 -0
  28. package/src/credits/constants.ts +30 -0
  29. package/src/credits/index.ts +46 -0
  30. package/src/credits/model-mapping.ts +92 -0
  31. package/src/credits/reservations.ts +514 -0
  32. package/src/credits/resolve-plan-model.ts +219 -0
  33. package/src/credits/sync-gateway-models.ts +351 -0
  34. package/src/credits/types.ts +109 -0
  35. package/src/credits/use-ai-credits.ts +3 -0
  36. package/src/embeddings/metered.ts +283 -0
  37. package/src/executions/route.ts +137 -0
  38. package/src/generate/route.ts +411 -0
  39. package/src/hooks.ts +7 -0
  40. package/src/meetings/summary/route.ts +7 -0
  41. package/src/meetings/transcription/route.ts +134 -0
  42. package/src/memory/client.ts +158 -0
  43. package/src/memory/config.ts +38 -0
  44. package/src/memory/index.ts +32 -0
  45. package/src/memory/ingest.ts +51 -0
  46. package/src/memory/middleware.ts +35 -0
  47. package/src/memory/operations.ts +480 -0
  48. package/src/memory/scope.ts +102 -0
  49. package/src/memory/settings.ts +121 -0
  50. package/src/memory/types.ts +101 -0
  51. package/src/memory/workspace.ts +36 -0
  52. package/src/memory.ts +1 -0
  53. package/src/mind/patch.ts +146 -0
  54. package/src/mind/route.ts +687 -0
  55. package/src/mind/tools.ts +1500 -0
  56. package/src/mind/types.ts +20 -0
  57. package/src/object/core.ts +3 -0
  58. package/src/object/flashcards/route.ts +140 -0
  59. package/src/object/quizzes/explanation/route.ts +145 -0
  60. package/src/object/quizzes/route.ts +142 -0
  61. package/src/object/types.ts +187 -0
  62. package/src/object/year-plan/route.ts +196 -0
  63. package/src/react.ts +1 -0
  64. package/src/scheduling/algorithm.ts +791 -0
  65. package/src/scheduling/default.ts +36 -0
  66. package/src/scheduling/duration-optimizer.ts +689 -0
  67. package/src/scheduling/index.ts +79 -0
  68. package/src/scheduling/priority-calculator.ts +187 -0
  69. package/src/scheduling/recurrence-calculator.ts +621 -0
  70. package/src/scheduling/templates.ts +892 -0
  71. package/src/scheduling/types.ts +136 -0
  72. package/src/scheduling/web-adapter.ts +308 -0
  73. package/src/scheduling.ts +6 -0
  74. package/src/supported-actions.ts +1 -0
  75. package/src/supported-providers.ts +6 -0
  76. package/src/tools/context-builder.ts +372 -0
  77. package/src/tools/core.ts +1 -0
  78. package/src/tools/definitions/calendar.ts +106 -0
  79. package/src/tools/definitions/finance.ts +197 -0
  80. package/src/tools/definitions/image.ts +74 -0
  81. package/src/tools/definitions/memory.ts +83 -0
  82. package/src/tools/definitions/meta.ts +154 -0
  83. package/src/tools/definitions/render-ui.ts +81 -0
  84. package/src/tools/definitions/tasks.ts +343 -0
  85. package/src/tools/definitions/time-tracking.ts +381 -0
  86. package/src/tools/definitions/workspace-context.ts +45 -0
  87. package/src/tools/definitions/workspace-user-chat.ts +111 -0
  88. package/src/tools/executors/calendar.ts +371 -0
  89. package/src/tools/executors/chat.ts +15 -0
  90. package/src/tools/executors/finance.ts +638 -0
  91. package/src/tools/executors/helpers/encryption.ts +107 -0
  92. package/src/tools/executors/image.ts +247 -0
  93. package/src/tools/executors/markitdown.ts +684 -0
  94. package/src/tools/executors/memory.ts +277 -0
  95. package/src/tools/executors/parallel-checks.ts +176 -0
  96. package/src/tools/executors/qr.ts +170 -0
  97. package/src/tools/executors/scope-helpers.ts +192 -0
  98. package/src/tools/executors/search.ts +149 -0
  99. package/src/tools/executors/settings.ts +40 -0
  100. package/src/tools/executors/tasks.ts +1087 -0
  101. package/src/tools/executors/theme.ts +23 -0
  102. package/src/tools/executors/timer/timer-categories-executor.ts +110 -0
  103. package/src/tools/executors/timer/timer-category-mutations.ts +240 -0
  104. package/src/tools/executors/timer/timer-goal-mutations.ts +323 -0
  105. package/src/tools/executors/timer/timer-goals-executor.ts +272 -0
  106. package/src/tools/executors/timer/timer-helpers.ts +372 -0
  107. package/src/tools/executors/timer/timer-mutation-schemas.ts +160 -0
  108. package/src/tools/executors/timer/timer-mutation-types.ts +212 -0
  109. package/src/tools/executors/timer/timer-mutations.ts +19 -0
  110. package/src/tools/executors/timer/timer-queries.ts +18 -0
  111. package/src/tools/executors/timer/timer-session-lifecycle.ts +299 -0
  112. package/src/tools/executors/timer/timer-session-mutations.ts +10 -0
  113. package/src/tools/executors/timer/timer-session-queries.ts +153 -0
  114. package/src/tools/executors/timer/timer-session-updates.ts +200 -0
  115. package/src/tools/executors/timer/timer-sessions-executor.ts +91 -0
  116. package/src/tools/executors/timer/timer-stats-executor.ts +157 -0
  117. package/src/tools/executors/timer.ts +22 -0
  118. package/src/tools/executors/user.ts +60 -0
  119. package/src/tools/executors/workspace.ts +135 -0
  120. package/src/tools/json-render-catalog.ts +875 -0
  121. package/src/tools/mira-tool-definitions.ts +55 -0
  122. package/src/tools/mira-tool-dispatcher.ts +265 -0
  123. package/src/tools/mira-tool-metadata.ts +164 -0
  124. package/src/tools/mira-tool-names.ts +95 -0
  125. package/src/tools/mira-tool-render-ui.ts +54 -0
  126. package/src/tools/mira-tool-types.ts +17 -0
  127. package/src/tools/mira-tools.ts +167 -0
  128. package/src/tools/normalize-render-ui-input.ts +321 -0
  129. package/src/tools/workspace-context.ts +233 -0
  130. package/src/types.ts +38 -0
@@ -0,0 +1,684 @@
1
+ import { createAdminClient } from '@tuturuuu/supabase/next/server';
2
+ import {
3
+ commitFixedAiCreditReservation,
4
+ releaseFixedAiCreditReservation,
5
+ reserveFixedAiCredits,
6
+ } from '../../credits/reservations';
7
+ import type { MiraToolContext } from '../mira-tools';
8
+
9
+ const MARKITDOWN_COST_CREDITS = 100;
10
+ const CREDIT_FEATURE = 'chat' as const;
11
+ const MARKITDOWN_LEDGER_MODEL = 'markitdown/conversion';
12
+ const DEFAULT_MARKITDOWN_TIMEOUT_MS = 30_000;
13
+ const MIN_MARKITDOWN_TIMEOUT_MS = 1_000;
14
+ const YOUTUBE_HOSTS = new Set([
15
+ 'youtube.com',
16
+ 'www.youtube.com',
17
+ 'm.youtube.com',
18
+ 'music.youtube.com',
19
+ 'youtube-nocookie.com',
20
+ 'www.youtube-nocookie.com',
21
+ 'youtu.be',
22
+ 'www.youtu.be',
23
+ ]);
24
+
25
+ function stripTimestampPrefix(name: string): string {
26
+ const match = name.match(/^\d+_(.+)$/);
27
+ return match?.[1] ?? name;
28
+ }
29
+
30
+ function isUnsafeStoragePath(value: string): boolean {
31
+ return value.includes('..') || value.includes('\\');
32
+ }
33
+
34
+ function isLikelyBareFileName(value: string): boolean {
35
+ return Boolean(value) && !value.includes('/') && !value.includes(':');
36
+ }
37
+
38
+ function parseUserGroupStoragePath(value: string, wsId: string) {
39
+ const prefix = `${wsId}/user-groups/`;
40
+ if (!value.startsWith(prefix)) return null;
41
+
42
+ const remainingPath = value.slice(prefix.length);
43
+ const firstSlashIndex = remainingPath.indexOf('/');
44
+ if (firstSlashIndex <= 0 || firstSlashIndex === remainingPath.length - 1) {
45
+ return null;
46
+ }
47
+
48
+ return {
49
+ groupId: remainingPath.slice(0, firstSlashIndex),
50
+ storagePath: value,
51
+ };
52
+ }
53
+
54
+ function getStoragePathFileName(value: string): string {
55
+ return value.split('/').pop() ?? value;
56
+ }
57
+
58
+ function normalizeYoutubeUrl(value: string): string | null {
59
+ try {
60
+ const parsed = new URL(value);
61
+ const hostname = parsed.hostname.toLowerCase();
62
+ if (parsed.protocol !== 'https:') return null;
63
+ if (!YOUTUBE_HOSTS.has(hostname)) return null;
64
+
65
+ if (hostname === 'youtu.be' || hostname === 'www.youtu.be') {
66
+ return parsed.pathname.length > 1 ? parsed.toString() : null;
67
+ }
68
+
69
+ const isKnownYoutubePath =
70
+ parsed.pathname === '/watch' ||
71
+ parsed.pathname.startsWith('/shorts/') ||
72
+ parsed.pathname.startsWith('/embed/') ||
73
+ parsed.pathname.startsWith('/live/');
74
+
75
+ if (!isKnownYoutubePath) return null;
76
+ if (parsed.pathname === '/watch' && !parsed.searchParams.get('v')) {
77
+ return null;
78
+ }
79
+
80
+ return parsed.toString();
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ function parseBaseUrl(value: string): string | null {
87
+ try {
88
+ const parsed = new URL(value);
89
+ const isLocalhost =
90
+ parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1';
91
+ const isDockerInternalHost =
92
+ parsed.hostname === 'markitdown' ||
93
+ parsed.hostname === 'host.docker.internal';
94
+
95
+ // Require HTTPS, except for local and Docker-internal service calls.
96
+ if (
97
+ parsed.protocol !== 'https:' &&
98
+ !((isLocalhost || isDockerInternalHost) && parsed.protocol === 'http:')
99
+ ) {
100
+ return null;
101
+ }
102
+
103
+ parsed.search = '';
104
+ parsed.hash = '';
105
+ return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, '');
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ function resolveConfiguredMarkitdownUrl(): string | null {
112
+ const endpointUrl = process.env.MARKITDOWN_ENDPOINT_URL?.trim();
113
+ if (!endpointUrl) return null;
114
+
115
+ const normalizedEndpointUrl = parseBaseUrl(endpointUrl);
116
+ if (!normalizedEndpointUrl) return null;
117
+ return normalizedEndpointUrl;
118
+ }
119
+
120
+ function resolveDiscordMarkitdownUrl(): string | null {
121
+ const deploymentUrl = process.env.DISCORD_APP_DEPLOYMENT_URL?.trim();
122
+ if (!deploymentUrl) return null;
123
+ const normalizedBaseUrl = parseBaseUrl(deploymentUrl);
124
+ if (!normalizedBaseUrl) return null;
125
+ return `${normalizedBaseUrl}/markitdown`;
126
+ }
127
+
128
+ function resolveDiscordMarkitdownSecret(): string | null {
129
+ return (
130
+ process.env.MARKITDOWN_ENDPOINT_SECRET?.trim() ||
131
+ process.env.VERCEL_CRON_SECRET?.trim() ||
132
+ process.env.CRON_SECRET?.trim() ||
133
+ null
134
+ );
135
+ }
136
+
137
+ function resolveMarkitdownTimeoutMs(): number {
138
+ const rawTimeoutMs = process.env.MARKITDOWN_TIMEOUT_MS?.trim();
139
+ if (!rawTimeoutMs) {
140
+ return DEFAULT_MARKITDOWN_TIMEOUT_MS;
141
+ }
142
+
143
+ const parsedTimeoutMs = Number(rawTimeoutMs);
144
+ if (!Number.isFinite(parsedTimeoutMs) || parsedTimeoutMs <= 0) {
145
+ return DEFAULT_MARKITDOWN_TIMEOUT_MS;
146
+ }
147
+
148
+ return Math.max(MIN_MARKITDOWN_TIMEOUT_MS, Math.floor(parsedTimeoutMs));
149
+ }
150
+
151
+ async function reserveMarkitdownCredits(
152
+ sbAdmin: { rpc: unknown },
153
+ billingWsId: string,
154
+ ctx: MiraToolContext,
155
+ metadata: Record<string, unknown>
156
+ ): Promise<
157
+ | { ok: true; reservationId: string; remainingCredits: number }
158
+ | { ok: false; error: string }
159
+ > {
160
+ const result = await reserveFixedAiCredits(
161
+ {
162
+ wsId: billingWsId,
163
+ userId: ctx.userId,
164
+ amount: MARKITDOWN_COST_CREDITS,
165
+ modelId: MARKITDOWN_LEDGER_MODEL,
166
+ feature: CREDIT_FEATURE,
167
+ metadata: {
168
+ ...metadata,
169
+ fixedCredits: MARKITDOWN_COST_CREDITS,
170
+ source: 'markitdown_tool',
171
+ },
172
+ },
173
+ sbAdmin
174
+ );
175
+
176
+ if (!result.success || !result.reservationId) {
177
+ if (result.errorCode === 'INSUFFICIENT_CREDITS') {
178
+ return {
179
+ ok: false,
180
+ error: `Insufficient credits. This conversion needs ${MARKITDOWN_COST_CREDITS} credits.`,
181
+ };
182
+ }
183
+ return { ok: false, error: 'Failed to reserve AI credits.' };
184
+ }
185
+
186
+ return {
187
+ ok: true,
188
+ reservationId: result.reservationId,
189
+ remainingCredits: result.remainingCredits,
190
+ };
191
+ }
192
+
193
+ async function commitMarkitdownCredits(
194
+ sbAdmin: { rpc: unknown },
195
+ reservationId: string,
196
+ metadata: Record<string, unknown>
197
+ ): Promise<
198
+ { ok: true; remainingCredits: number } | { ok: false; error: string }
199
+ > {
200
+ const result = await commitFixedAiCreditReservation(
201
+ reservationId,
202
+ {
203
+ ...metadata,
204
+ fixedCredits: MARKITDOWN_COST_CREDITS,
205
+ source: 'markitdown_tool',
206
+ },
207
+ sbAdmin
208
+ );
209
+
210
+ if (!result.success) {
211
+ if (result.errorCode === 'RESERVATION_EXPIRED') {
212
+ return {
213
+ ok: false,
214
+ error:
215
+ 'AI credit reservation expired before the conversion could be completed.',
216
+ };
217
+ }
218
+ return { ok: false, error: 'Failed to deduct AI credits.' };
219
+ }
220
+
221
+ return {
222
+ ok: true,
223
+ remainingCredits: result.remainingCredits,
224
+ };
225
+ }
226
+
227
+ async function releaseMarkitdownCredits(
228
+ sbAdmin: { rpc: unknown },
229
+ reservationId: string,
230
+ metadata: Record<string, unknown>
231
+ ): Promise<void> {
232
+ const result = await releaseFixedAiCreditReservation(
233
+ reservationId,
234
+ {
235
+ ...metadata,
236
+ fixedCredits: MARKITDOWN_COST_CREDITS,
237
+ source: 'markitdown_tool',
238
+ },
239
+ sbAdmin
240
+ );
241
+
242
+ if (!result.success) {
243
+ console.error('MarkItDown: failed to release AI credit reservation:', {
244
+ reservationId,
245
+ errorCode: result.errorCode,
246
+ });
247
+ }
248
+ }
249
+
250
+ export async function executeConvertFileToMarkdown(
251
+ args: Record<string, unknown>,
252
+ ctx: MiraToolContext
253
+ ) {
254
+ const billingWsId = ctx.creditWsId ?? ctx.wsId;
255
+ const markitdownUrl =
256
+ resolveConfiguredMarkitdownUrl() ?? resolveDiscordMarkitdownUrl();
257
+ const markitdownSecret = resolveDiscordMarkitdownSecret();
258
+
259
+ if (!markitdownUrl) {
260
+ return {
261
+ ok: false,
262
+ error:
263
+ 'MarkItDown endpoint is not configured. Set MARKITDOWN_ENDPOINT_URL or DISCORD_APP_DEPLOYMENT_URL.',
264
+ };
265
+ }
266
+
267
+ if (!markitdownSecret) {
268
+ return {
269
+ ok: false,
270
+ error:
271
+ 'MarkItDown endpoint secret is not configured. Set MARKITDOWN_ENDPOINT_SECRET or CRON secret.',
272
+ };
273
+ }
274
+
275
+ const storagePathArg =
276
+ typeof args.storagePath === 'string' ? args.storagePath.trim() : '';
277
+ const sourceUrlArg = typeof args.url === 'string' ? args.url.trim() : '';
278
+ const fileNameArg =
279
+ typeof args.fileName === 'string' ? args.fileName.trim() : '';
280
+ const maxCharactersRaw =
281
+ typeof args.maxCharacters === 'number' &&
282
+ Number.isFinite(args.maxCharacters)
283
+ ? Math.floor(args.maxCharacters)
284
+ : 120_000;
285
+ const maxCharacters = Math.min(Math.max(maxCharactersRaw, 10_000), 120_000);
286
+
287
+ const expectedPrefix = `${ctx.wsId}/chats/ai/resources/`;
288
+ const chatFolder = ctx.chatId
289
+ ? `${ctx.wsId}/chats/ai/resources/${ctx.chatId}`
290
+ : '';
291
+ let targetPath = storagePathArg;
292
+ let sourceUrl = sourceUrlArg ? normalizeYoutubeUrl(sourceUrlArg) : null;
293
+ let selectedFileName = '';
294
+
295
+ if (sourceUrlArg && !sourceUrl) {
296
+ return {
297
+ ok: false,
298
+ error:
299
+ 'Invalid URL for MarkItDown. Direct URL conversion currently supports HTTPS YouTube links only.',
300
+ };
301
+ }
302
+
303
+ if (!sourceUrl && targetPath) {
304
+ sourceUrl = normalizeYoutubeUrl(targetPath);
305
+ if (sourceUrl) {
306
+ targetPath = '';
307
+ }
308
+ }
309
+
310
+ if (sourceUrl) {
311
+ const markitdownTimeoutMs = resolveMarkitdownTimeoutMs();
312
+ const abortController = new AbortController();
313
+ const timeoutId = setTimeout(
314
+ () => abortController.abort(),
315
+ markitdownTimeoutMs
316
+ );
317
+
318
+ let metadataResponse: Response;
319
+ try {
320
+ metadataResponse = await fetch(markitdownUrl, {
321
+ method: 'POST',
322
+ headers: {
323
+ Authorization: `Bearer ${markitdownSecret}`,
324
+ 'Content-Type': 'application/json',
325
+ },
326
+ body: JSON.stringify({
327
+ url: sourceUrl,
328
+ filename: fileNameArg || 'youtube.md',
329
+ enable_plugins: true,
330
+ }),
331
+ signal: abortController.signal,
332
+ });
333
+ } catch (error) {
334
+ const message =
335
+ error instanceof Error && error.name === 'AbortError'
336
+ ? `YouTube metadata lookup timed out after ${markitdownTimeoutMs}ms.`
337
+ : 'Failed to reach YouTube metadata service.';
338
+ console.error('YouTube metadata request failed:', error);
339
+ return { ok: false, error: message };
340
+ } finally {
341
+ clearTimeout(timeoutId);
342
+ }
343
+
344
+ if (!metadataResponse.ok) {
345
+ const rawBody = await metadataResponse.text().catch(() => '');
346
+ const safeMessage = rawBody.replace(/\s+/g, ' ').trim().slice(0, 300);
347
+ console.error('YouTube metadata lookup failed:', {
348
+ status: metadataResponse.status,
349
+ body: safeMessage,
350
+ });
351
+ return {
352
+ ok: false,
353
+ error: `YouTube metadata lookup failed (status ${metadataResponse.status}).`,
354
+ };
355
+ }
356
+
357
+ let payload: { title?: unknown };
358
+ try {
359
+ payload = (await metadataResponse.json()) as { title?: unknown };
360
+ } catch (error) {
361
+ console.error('YouTube metadata service returned invalid JSON:', error);
362
+ return { ok: false, error: 'YouTube metadata lookup failed.' };
363
+ }
364
+
365
+ const title =
366
+ typeof payload.title === 'string' && payload.title.trim()
367
+ ? payload.title.trim()
368
+ : null;
369
+
370
+ return {
371
+ ok: true,
372
+ title,
373
+ fileName: fileNameArg || null,
374
+ storagePath: null,
375
+ url: sourceUrl,
376
+ metadataOnly: true,
377
+ supportedCapabilities: ['youtube_title_metadata'],
378
+ unsupportedCapabilities: ['youtube_transcription', 'youtube_summary'],
379
+ message: title
380
+ ? `Only YouTube metadata is supported. Title: ${title}. YouTube transcripts and summaries are not supported.`
381
+ : 'Only YouTube metadata is supported. YouTube transcripts and summaries are not supported.',
382
+ };
383
+ }
384
+
385
+ if (!sourceUrl && targetPath) {
386
+ if (isUnsafeStoragePath(targetPath)) {
387
+ return { ok: false, error: 'Invalid storagePath for current workspace.' };
388
+ }
389
+
390
+ const userGroupStoragePath = parseUserGroupStoragePath(
391
+ targetPath,
392
+ ctx.wsId
393
+ );
394
+
395
+ if (chatFolder && targetPath.startsWith(`${chatFolder}/`)) {
396
+ selectedFileName = getStoragePathFileName(targetPath);
397
+ } else if (userGroupStoragePath) {
398
+ let canReadUserGroupStorage = false;
399
+ try {
400
+ canReadUserGroupStorage = Boolean(
401
+ await ctx.canReadUserGroupStorage?.({
402
+ groupId: userGroupStoragePath.groupId,
403
+ storagePath: userGroupStoragePath.storagePath,
404
+ wsId: ctx.wsId,
405
+ })
406
+ );
407
+ } catch {
408
+ canReadUserGroupStorage = false;
409
+ }
410
+
411
+ if (!canReadUserGroupStorage) {
412
+ return {
413
+ ok: false,
414
+ error: 'You do not have permission to read this user-group file.',
415
+ };
416
+ }
417
+
418
+ selectedFileName = getStoragePathFileName(targetPath);
419
+ } else if (isLikelyBareFileName(targetPath)) {
420
+ selectedFileName = targetPath;
421
+ targetPath = '';
422
+ } else if (chatFolder && targetPath.startsWith(expectedPrefix)) {
423
+ // The client can still show pre-move temp attachment paths while the
424
+ // server has already moved the object into the chat folder. Resolve the
425
+ // basename against the current chat instead of failing on stale metadata.
426
+ selectedFileName = fileNameArg || getStoragePathFileName(targetPath);
427
+ targetPath = '';
428
+ } else {
429
+ return { ok: false, error: 'Invalid storagePath for current workspace.' };
430
+ }
431
+ } else if (!sourceUrl) {
432
+ selectedFileName = fileNameArg;
433
+ }
434
+
435
+ const sbAdmin = await createAdminClient();
436
+
437
+ if (!sourceUrl && !targetPath) {
438
+ if (!ctx.chatId) {
439
+ return {
440
+ ok: false,
441
+ error:
442
+ 'No file specified and chat context is missing. Provide `storagePath`.',
443
+ };
444
+ }
445
+
446
+ const { data: listedFiles, error: listError } = await sbAdmin.storage
447
+ .from('workspaces')
448
+ .list(chatFolder, {
449
+ limit: 100,
450
+ sortBy: { column: 'created_at', order: 'desc' },
451
+ });
452
+
453
+ if (listError) {
454
+ return {
455
+ ok: false,
456
+ error: `Failed to list chat files: ${listError.message}`,
457
+ };
458
+ }
459
+
460
+ const realFiles = (listedFiles ?? []).filter(
461
+ (entry) => entry.id != null && entry.name !== '.emptyFolderPlaceholder'
462
+ );
463
+
464
+ if (realFiles.length === 0) {
465
+ return { ok: false, error: 'No files found in this chat.' };
466
+ }
467
+
468
+ const pickedFile = selectedFileName
469
+ ? realFiles.find(
470
+ (entry) =>
471
+ entry.name.toLowerCase() === selectedFileName.toLowerCase() ||
472
+ stripTimestampPrefix(entry.name).toLowerCase() ===
473
+ selectedFileName.toLowerCase()
474
+ )
475
+ : realFiles[0];
476
+
477
+ if (!pickedFile) {
478
+ return {
479
+ ok: false,
480
+ error: `File "${selectedFileName}" was not found in this chat.`,
481
+ };
482
+ }
483
+
484
+ selectedFileName = pickedFile.name;
485
+ targetPath = `${chatFolder}/${pickedFile.name}`;
486
+ }
487
+
488
+ const sourceLabel = sourceUrl
489
+ ? sourceUrl
490
+ : stripTimestampPrefix(selectedFileName);
491
+
492
+ const reservation = await reserveMarkitdownCredits(
493
+ sbAdmin,
494
+ billingWsId,
495
+ ctx,
496
+ {
497
+ ...(sourceUrl
498
+ ? { sourceType: 'youtube_url', sourceUrl }
499
+ : {
500
+ sourceType: 'storage_file',
501
+ targetPath,
502
+ selectedFileName: stripTimestampPrefix(selectedFileName),
503
+ }),
504
+ }
505
+ );
506
+
507
+ if (!reservation.ok) {
508
+ return {
509
+ ok: false,
510
+ error: reservation.error,
511
+ };
512
+ }
513
+
514
+ const reservationId = reservation.reservationId;
515
+ let shouldReleaseReservation = true;
516
+
517
+ try {
518
+ let signedReadUrl = '';
519
+ if (!sourceUrl) {
520
+ const { data: signedReadData, error: signedReadError } =
521
+ await sbAdmin.storage
522
+ .from('workspaces')
523
+ .createSignedUrl(targetPath, 120);
524
+
525
+ signedReadUrl = signedReadData?.signedUrl ?? '';
526
+
527
+ if (signedReadError || !signedReadUrl) {
528
+ return {
529
+ ok: false,
530
+ error: `Failed to create signed download URL: ${signedReadError?.message ?? 'No URL returned'}`,
531
+ };
532
+ }
533
+ }
534
+
535
+ const markitdownTimeoutMs = resolveMarkitdownTimeoutMs();
536
+ const abortController = new AbortController();
537
+ const timeoutId = setTimeout(
538
+ () => abortController.abort(),
539
+ markitdownTimeoutMs
540
+ );
541
+
542
+ let conversionResponse: Response;
543
+ try {
544
+ conversionResponse = await fetch(markitdownUrl, {
545
+ method: 'POST',
546
+ headers: {
547
+ Authorization: `Bearer ${markitdownSecret}`,
548
+ 'Content-Type': 'application/json',
549
+ },
550
+ body: JSON.stringify(
551
+ sourceUrl
552
+ ? {
553
+ url: sourceUrl,
554
+ filename: fileNameArg || 'youtube.md',
555
+ enable_plugins: true,
556
+ }
557
+ : {
558
+ signed_url: signedReadUrl,
559
+ filename: stripTimestampPrefix(selectedFileName),
560
+ enable_plugins: true,
561
+ }
562
+ ),
563
+ signal: abortController.signal,
564
+ });
565
+ } catch (error) {
566
+ const message =
567
+ error instanceof Error && error.name === 'AbortError'
568
+ ? `MarkItDown conversion timed out after ${markitdownTimeoutMs}ms.`
569
+ : 'Failed to reach MarkItDown conversion service.';
570
+ console.error('MarkItDown conversion request failed:', error);
571
+ return { ok: false, error: message };
572
+ } finally {
573
+ clearTimeout(timeoutId);
574
+ }
575
+
576
+ if (!conversionResponse.ok) {
577
+ const rawBody = await conversionResponse.text().catch(() => '');
578
+ const safeMessage = rawBody.replace(/\s+/g, ' ').trim().slice(0, 300);
579
+ console.error('MarkItDown conversion failed:', {
580
+ status: conversionResponse.status,
581
+ body: safeMessage,
582
+ });
583
+ return {
584
+ ok: false,
585
+ error: `MarkItDown conversion failed (status ${conversionResponse.status}).`,
586
+ };
587
+ }
588
+
589
+ let payload: { ok?: boolean; markdown?: unknown; title?: unknown };
590
+ try {
591
+ payload = (await conversionResponse.json()) as {
592
+ ok?: boolean;
593
+ markdown?: unknown;
594
+ title?: unknown;
595
+ };
596
+ } catch (error) {
597
+ console.error('MarkItDown returned invalid JSON response:', error);
598
+ return { ok: false, error: 'MarkItDown conversion failed.' };
599
+ }
600
+ const markdown =
601
+ typeof payload.markdown === 'string' ? payload.markdown.trim() : '';
602
+
603
+ if (!markdown) {
604
+ return { ok: false, error: 'MarkItDown returned empty markdown.' };
605
+ }
606
+
607
+ const wasTruncated = markdown.length > maxCharacters;
608
+ const finalMarkdown = wasTruncated
609
+ ? `${markdown.slice(0, maxCharacters)}\n\n[...truncated for token safety...]`
610
+ : markdown;
611
+
612
+ const deduction = await commitMarkitdownCredits(sbAdmin, reservationId, {
613
+ wsId: billingWsId,
614
+ userId: ctx.userId,
615
+ ...(sourceUrl
616
+ ? { sourceType: 'youtube_url', sourceUrl }
617
+ : {
618
+ sourceType: 'storage_file',
619
+ targetPath,
620
+ selectedFileName: stripTimestampPrefix(selectedFileName),
621
+ }),
622
+ markdownLength: markdown.length,
623
+ maxCharacters,
624
+ truncated: wasTruncated,
625
+ });
626
+
627
+ if (!deduction.ok) {
628
+ console.error(
629
+ 'MarkItDown: conversion succeeded but reserved credit commit failed:',
630
+ {
631
+ wsId: ctx.wsId,
632
+ userId: ctx.userId,
633
+ source: sourceLabel,
634
+ reservationId,
635
+ error: deduction.error,
636
+ }
637
+ );
638
+
639
+ return {
640
+ ok: false,
641
+ error: deduction.error,
642
+ title: typeof payload.title === 'string' ? payload.title : null,
643
+ fileName: sourceUrl
644
+ ? fileNameArg || null
645
+ : stripTimestampPrefix(selectedFileName),
646
+ storagePath: sourceUrl ? null : targetPath,
647
+ url: sourceUrl,
648
+ truncated: wasTruncated,
649
+ creditDeductionError: deduction.error,
650
+ };
651
+ }
652
+
653
+ shouldReleaseReservation = false;
654
+
655
+ return {
656
+ ok: true,
657
+ markdown: finalMarkdown,
658
+ title: typeof payload.title === 'string' ? payload.title : null,
659
+ fileName: sourceUrl
660
+ ? fileNameArg || null
661
+ : stripTimestampPrefix(selectedFileName),
662
+ storagePath: sourceUrl ? null : targetPath,
663
+ url: sourceUrl,
664
+ creditsCharged: MARKITDOWN_COST_CREDITS,
665
+ remainingCredits: deduction.remainingCredits,
666
+ truncated: wasTruncated,
667
+ };
668
+ } finally {
669
+ if (shouldReleaseReservation) {
670
+ await releaseMarkitdownCredits(sbAdmin, reservationId, {
671
+ wsId: ctx.wsId,
672
+ userId: ctx.userId,
673
+ ...(sourceUrl
674
+ ? { sourceType: 'youtube_url', sourceUrl }
675
+ : {
676
+ sourceType: 'storage_file',
677
+ targetPath,
678
+ selectedFileName: stripTimestampPrefix(selectedFileName),
679
+ }),
680
+ reason: 'markitdown_execution_failed',
681
+ });
682
+ }
683
+ }
684
+ }