@fayz-ai/plugin-scribe 0.10.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 (60) hide show
  1. package/LICENSE +21 -0
  2. package/dist/components/DraftPanel.d.ts +14 -0
  3. package/dist/components/DraftPanel.d.ts.map +1 -0
  4. package/dist/components/ScribeConsentDialog.d.ts +12 -0
  5. package/dist/components/ScribeConsentDialog.d.ts.map +1 -0
  6. package/dist/components/ScribeRecordingPill.d.ts +16 -0
  7. package/dist/components/ScribeRecordingPill.d.ts.map +1 -0
  8. package/dist/components/ScribeRecoveryBanner.d.ts +8 -0
  9. package/dist/components/ScribeRecoveryBanner.d.ts.map +1 -0
  10. package/dist/components/ScribeSessionPage.d.ts +13 -0
  11. package/dist/components/ScribeSessionPage.d.ts.map +1 -0
  12. package/dist/components/ScribeShellMount.d.ts +19 -0
  13. package/dist/components/ScribeShellMount.d.ts.map +1 -0
  14. package/dist/components/TranscriptPanel.d.ts +10 -0
  15. package/dist/components/TranscriptPanel.d.ts.map +1 -0
  16. package/dist/data/supabase.d.ts +63 -0
  17. package/dist/data/supabase.d.ts.map +1 -0
  18. package/dist/data/tables.d.ts +31 -0
  19. package/dist/data/tables.d.ts.map +1 -0
  20. package/dist/index.d.ts +13 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/index.js +2910 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/lib/config.d.ts +19 -0
  25. package/dist/lib/config.d.ts.map +1 -0
  26. package/dist/lib/config.test.d.ts +2 -0
  27. package/dist/lib/config.test.d.ts.map +1 -0
  28. package/dist/lib/drain.d.ts +13 -0
  29. package/dist/lib/drain.d.ts.map +1 -0
  30. package/dist/lib/generate.d.ts +23 -0
  31. package/dist/lib/generate.d.ts.map +1 -0
  32. package/dist/lib/prompt.d.ts +39 -0
  33. package/dist/lib/prompt.d.ts.map +1 -0
  34. package/dist/lib/prompt.test.d.ts +2 -0
  35. package/dist/lib/prompt.test.d.ts.map +1 -0
  36. package/dist/lib/transport.d.ts +59 -0
  37. package/dist/lib/transport.d.ts.map +1 -0
  38. package/dist/locales/en.d.ts +2 -0
  39. package/dist/locales/en.d.ts.map +1 -0
  40. package/dist/locales/index.d.ts +2 -0
  41. package/dist/locales/index.d.ts.map +1 -0
  42. package/dist/locales/pt-BR.d.ts +2 -0
  43. package/dist/locales/pt-BR.d.ts.map +1 -0
  44. package/dist/migrations/index.d.ts +6 -0
  45. package/dist/migrations/index.d.ts.map +1 -0
  46. package/dist/runtime/capture.d.ts +84 -0
  47. package/dist/runtime/capture.d.ts.map +1 -0
  48. package/dist/runtime/idb.d.ts +121 -0
  49. package/dist/runtime/idb.d.ts.map +1 -0
  50. package/dist/runtime/index.d.ts +48 -0
  51. package/dist/runtime/index.d.ts.map +1 -0
  52. package/dist/runtime/pump.d.ts +11 -0
  53. package/dist/runtime/pump.d.ts.map +1 -0
  54. package/dist/store.d.ts +25 -0
  55. package/dist/store.d.ts.map +1 -0
  56. package/dist/types.d.ts +266 -0
  57. package/dist/types.d.ts.map +1 -0
  58. package/functions/scribe-generate/index.ts +255 -0
  59. package/functions/scribe-transcribe/index.ts +455 -0
  60. package/package.json +58 -0
@@ -0,0 +1,455 @@
1
+ // scribe-transcribe — transcreve os segmentos de áudio de uma sessão.
2
+ //
3
+ // Ações:
4
+ // drain → transcreve tudo o que está pendente numa sessão (o caso normal)
5
+ // segment → transcreve um segmento específico (retry manual / debug)
6
+ //
7
+ // Env: DEEPGRAM_API_KEY (ou OPENAI_API_KEY), SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY.
8
+ //
9
+ // ---------------------------------------------------------------------------
10
+ // Por que a credencial mora aqui e não no browser
11
+ // ---------------------------------------------------------------------------
12
+ // Duas razões, e a segunda é a que importa. A primeira é óbvia: uma chave de STT
13
+ // no bundle é uma chave pública. A segunda é que o áudio deste bucket é privado
14
+ // e o browser não tem — nem deve ter — permissão para mandá-lo a um terceiro.
15
+ // A função lê com service key e fala com o provedor; a fronteira do dado
16
+ // sensível fica em um lugar só, auditável.
17
+ //
18
+ // ---------------------------------------------------------------------------
19
+ // Por que por SEGMENTO e não o arquivo inteiro
20
+ // ---------------------------------------------------------------------------
21
+ // Custo é IGUAL nos dois provedores (cobram por minuto de áudio, não por
22
+ // requisição), então custo não decide. Decide o modo de falha:
23
+ //
24
+ // - o texto aparece DURANTE a consulta, que é o requisito de produto
25
+ // - o request tem tamanho limitado (2 h de sessão estoura o teto de 25 MB do
26
+ // Whisper num arquivo só; segmentado, o teto some do mapa)
27
+ // - um segmento envenenado perde 30 s, não 40 minutos
28
+ // - retry é uma coluna, não um reprocessamento inteiro
29
+ // - quem para no minuto 12 tem 12 minutos de transcrição
30
+ //
31
+ // ---------------------------------------------------------------------------
32
+ // Contexto de fronteira
33
+ // ---------------------------------------------------------------------------
34
+ // Rotacionar o gravador a cada 30 s corta palavras na emenda. Mitigação barata:
35
+ // semear cada request com a CAUDA do texto do segmento anterior (`prompt` no
36
+ // Whisper, `keyterm` no Deepgram). O provedor usa isso como pista de contexto e
37
+ // a emenda para de comer nome próprio e termo técnico.
38
+ import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.45.0'
39
+
40
+ const corsHeaders = {
41
+ 'Access-Control-Allow-Origin': '*',
42
+ 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
43
+ }
44
+
45
+ const AUDIO_BUCKET = 'scribe-audio'
46
+ /** Quantos segmentos transcrevem em paralelo por invocação. */
47
+ const CONCURRENCY = 3
48
+ /** Teto de tentativas antes de desistir de um segmento e seguir com o resto. */
49
+ const MAX_ATTEMPTS = 4
50
+ /** Quantos caracteres do segmento anterior viram pista de contexto. */
51
+ const CONTEXT_TAIL_CHARS = 200
52
+
53
+ interface TranscriptWord {
54
+ w: string
55
+ s: number
56
+ e: number
57
+ c?: number
58
+ sp?: number
59
+ }
60
+
61
+ interface SttResult {
62
+ text: string
63
+ words: TranscriptWord[]
64
+ durationMs: number
65
+ confidence: number
66
+ provider: string
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Deepgram
71
+ // ---------------------------------------------------------------------------
72
+
73
+ async function transcribeDeepgram(
74
+ audio: ArrayBuffer,
75
+ mimeType: string,
76
+ opts: { locale: string; diarize: boolean; model: string; context?: string },
77
+ ): Promise<SttResult> {
78
+ const key = Deno.env.get('DEEPGRAM_API_KEY')
79
+ if (!key) throw new Error('DEEPGRAM_API_KEY não configurada')
80
+
81
+ const params = new URLSearchParams({
82
+ model: opts.model,
83
+ language: opts.locale,
84
+ punctuate: 'true',
85
+ smart_format: 'true',
86
+ diarize: String(opts.diarize),
87
+ })
88
+ // `keyterm` só existe no Nova-3; mandar em outro modelo é erro 400.
89
+ if (opts.context && opts.model.startsWith('nova-3')) {
90
+ for (const term of extractKeyterms(opts.context)) params.append('keyterm', term)
91
+ }
92
+
93
+ const res = await fetch(`https://api.deepgram.com/v1/listen?${params}`, {
94
+ method: 'POST',
95
+ headers: { Authorization: `Token ${key}`, 'Content-Type': mimeType },
96
+ body: audio,
97
+ })
98
+ if (!res.ok) throw new Error(`Deepgram ${res.status}: ${await res.text()}`)
99
+
100
+ const json = await res.json()
101
+ const alt = json?.results?.channels?.[0]?.alternatives?.[0]
102
+ const words: TranscriptWord[] = (alt?.words ?? []).map((w: any) => ({
103
+ w: w.punctuated_word ?? w.word,
104
+ s: Math.round((w.start ?? 0) * 1000),
105
+ e: Math.round((w.end ?? 0) * 1000),
106
+ c: w.confidence,
107
+ sp: w.speaker,
108
+ }))
109
+
110
+ return {
111
+ text: alt?.transcript ?? '',
112
+ words,
113
+ durationMs: Math.round((json?.metadata?.duration ?? 0) * 1000),
114
+ confidence: alt?.confidence ?? 0,
115
+ provider: `deepgram:${opts.model}`,
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Termos que valem como pista: palavras longas o bastante para serem nome
121
+ * próprio ou jargão. Artigo e preposição não ajudam o reconhecedor e só gastam
122
+ * espaço no limite de keyterms.
123
+ */
124
+ function extractKeyterms(context: string): string[] {
125
+ const seen = new Set<string>()
126
+ for (const word of context.split(/\s+/)) {
127
+ const clean = word.replace(/[^\p{L}\p{N}-]/gu, '')
128
+ if (clean.length >= 6) seen.add(clean)
129
+ if (seen.size >= 10) break
130
+ }
131
+ return [...seen]
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // OpenAI (o segundo provedor atrás do mesmo seam)
136
+ // ---------------------------------------------------------------------------
137
+
138
+ async function transcribeOpenAI(
139
+ audio: ArrayBuffer,
140
+ mimeType: string,
141
+ opts: { locale: string; model: string; context?: string },
142
+ ): Promise<SttResult> {
143
+ const key = Deno.env.get('OPENAI_API_KEY')
144
+ if (!key) throw new Error('OPENAI_API_KEY não configurada')
145
+
146
+ const form = new FormData()
147
+ form.append('file', new Blob([audio], { type: mimeType }), 'segment.webm')
148
+ form.append('model', opts.model)
149
+ form.append('language', opts.locale.slice(0, 2))
150
+ form.append('response_format', 'verbose_json')
151
+ form.append('timestamp_granularities[]', 'word')
152
+ if (opts.context) form.append('prompt', opts.context)
153
+
154
+ const res = await fetch('https://api.openai.com/v1/audio/transcriptions', {
155
+ method: 'POST',
156
+ headers: { Authorization: `Bearer ${key}` },
157
+ body: form,
158
+ })
159
+ if (!res.ok) throw new Error(`OpenAI ${res.status}: ${await res.text()}`)
160
+
161
+ const json = await res.json()
162
+ const words: TranscriptWord[] = (json?.words ?? []).map((w: any) => ({
163
+ w: w.word,
164
+ s: Math.round((w.start ?? 0) * 1000),
165
+ e: Math.round((w.end ?? 0) * 1000),
166
+ }))
167
+
168
+ return {
169
+ text: json?.text ?? '',
170
+ words,
171
+ durationMs: Math.round((json?.duration ?? 0) * 1000),
172
+ confidence: 0,
173
+ provider: `openai:${opts.model}`,
174
+ }
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // O seam de UM método
179
+ // ---------------------------------------------------------------------------
180
+
181
+ function transcribe(
182
+ audio: ArrayBuffer,
183
+ mimeType: string,
184
+ opts: { provider: string; model?: string; locale: string; diarize: boolean; context?: string },
185
+ ): Promise<SttResult> {
186
+ if (opts.provider === 'openai') {
187
+ return transcribeOpenAI(audio, mimeType, {
188
+ locale: opts.locale,
189
+ model: opts.model ?? 'whisper-1',
190
+ context: opts.context,
191
+ })
192
+ }
193
+ return transcribeDeepgram(audio, mimeType, {
194
+ locale: opts.locale,
195
+ diarize: opts.diarize,
196
+ model: opts.model ?? 'nova-3',
197
+ context: opts.context,
198
+ })
199
+ }
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // Drenagem
203
+ // ---------------------------------------------------------------------------
204
+
205
+ async function drainSession(
206
+ supabase: any,
207
+ sessionId: string,
208
+ provider: string,
209
+ model: string | undefined,
210
+ diarize: boolean,
211
+ ): Promise<{ transcribed: number; failed: number; remaining: number }> {
212
+ const { data: session, error: sessionErr } = await supabase
213
+ .from('plg_scribe_sessions')
214
+ .select('id, tenant_id, locale, mime_type, status')
215
+ .eq('id', sessionId)
216
+ .single()
217
+ if (sessionErr || !session) throw new Error('sessão não encontrada')
218
+
219
+ const { data: pending } = await supabase
220
+ .from('plg_scribe_segments')
221
+ .select('session_id, seg_index, storage_path, stt_attempts')
222
+ .eq('session_id', sessionId)
223
+ .eq('upload_state', 'uploaded')
224
+ .in('stt_state', ['pending', 'failed'])
225
+ .lt('stt_attempts', MAX_ATTEMPTS)
226
+ .order('seg_index', { ascending: true })
227
+
228
+ if (!pending || pending.length === 0) {
229
+ await maybeMarkReady(supabase, sessionId)
230
+ return { transcribed: 0, failed: 0, remaining: 0 }
231
+ }
232
+
233
+ let transcribed = 0
234
+ let failed = 0
235
+
236
+ for (let i = 0; i < pending.length; i += CONCURRENCY) {
237
+ const batch = pending.slice(i, i + CONCURRENCY)
238
+ const results = await Promise.all(
239
+ batch.map(async (seg: any) => {
240
+ if (!seg.storage_path) return { ok: false }
241
+ try {
242
+ // Marcar `running` ANTES de baixar: duas invocações concorrentes
243
+ // (browser + cron) veriam o mesmo segmento pendente, e transcrever
244
+ // duas vezes é dinheiro jogado fora.
245
+ await supabase
246
+ .from('plg_scribe_segments')
247
+ .update({ stt_state: 'running', stt_attempts: (seg.stt_attempts ?? 0) + 1, updated_at: new Date().toISOString() })
248
+ .eq('session_id', seg.session_id)
249
+ .eq('seg_index', seg.seg_index)
250
+
251
+ const { data: file, error: dlErr } = await supabase.storage
252
+ .from(AUDIO_BUCKET)
253
+ .download(seg.storage_path)
254
+ if (dlErr || !file) throw new Error(`download falhou: ${dlErr?.message ?? 'sem corpo'}`)
255
+
256
+ const context = await previousTail(supabase, sessionId, seg.seg_index)
257
+ const audio = await file.arrayBuffer()
258
+ const result = await transcribe(audio, session.mime_type ?? 'audio/webm', {
259
+ provider,
260
+ model,
261
+ locale: session.locale ?? 'pt-BR',
262
+ diarize,
263
+ context,
264
+ })
265
+
266
+ await supabase
267
+ .from('plg_scribe_segments')
268
+ .update({
269
+ stt_state: 'done',
270
+ stt_provider: result.provider,
271
+ stt_error: null,
272
+ text: result.text,
273
+ words: result.words,
274
+ confidence: result.confidence,
275
+ // A duração AUTORITATIVA: medida no áudio, não contada no JS.
276
+ duration_ms: result.durationMs > 0 ? result.durationMs : null,
277
+ updated_at: new Date().toISOString(),
278
+ })
279
+ .eq('session_id', seg.session_id)
280
+ .eq('seg_index', seg.seg_index)
281
+
282
+ return { ok: true }
283
+ } catch (err) {
284
+ await supabase
285
+ .from('plg_scribe_segments')
286
+ .update({
287
+ stt_state: 'failed',
288
+ stt_error: String((err as Error)?.message ?? err).slice(0, 500),
289
+ updated_at: new Date().toISOString(),
290
+ })
291
+ .eq('session_id', seg.session_id)
292
+ .eq('seg_index', seg.seg_index)
293
+ return { ok: false }
294
+ }
295
+ }),
296
+ )
297
+ transcribed += results.filter((r) => r.ok).length
298
+ failed += results.filter((r) => !r.ok).length
299
+ }
300
+
301
+ await recomputeCounters(supabase, sessionId)
302
+ await maybeMarkReady(supabase, sessionId)
303
+
304
+ const { count } = await supabase
305
+ .from('plg_scribe_segments')
306
+ .select('seg_index', { count: 'exact', head: true })
307
+ .eq('session_id', sessionId)
308
+ .eq('upload_state', 'uploaded')
309
+ .in('stt_state', ['pending', 'failed'])
310
+ .lt('stt_attempts', MAX_ATTEMPTS)
311
+
312
+ return { transcribed, failed, remaining: count ?? 0 }
313
+ }
314
+
315
+ /** Cauda do segmento anterior — a pista de contexto que salva a palavra da emenda. */
316
+ async function previousTail(supabase: any, sessionId: string, segIndex: number): Promise<string | undefined> {
317
+ if (segIndex === 0) return undefined
318
+ const { data } = await supabase
319
+ .from('plg_scribe_segments')
320
+ .select('text')
321
+ .eq('session_id', sessionId)
322
+ .eq('seg_index', segIndex - 1)
323
+ .maybeSingle()
324
+ const text = data?.text as string | undefined
325
+ return text ? text.slice(-CONTEXT_TAIL_CHARS) : undefined
326
+ }
327
+
328
+ /**
329
+ * Recalcula os contadores a partir dos segmentos — mesma razão do RPC do
330
+ * cliente: derivar, nunca incrementar.
331
+ */
332
+ async function recomputeCounters(supabase: any, sessionId: string): Promise<void> {
333
+ const { data: segs } = await supabase
334
+ .from('plg_scribe_segments')
335
+ .select('stt_state, duration_ms, text, gap')
336
+ .eq('session_id', sessionId)
337
+ if (!segs) return
338
+
339
+ const transcribed = segs.filter((s: any) => s.stt_state === 'done').length
340
+ const audioMs = segs.filter((s: any) => !s.gap).reduce((acc: number, s: any) => acc + (s.duration_ms ?? 0), 0)
341
+ const chars = segs.reduce((acc: number, s: any) => acc + (s.text?.length ?? 0), 0)
342
+
343
+ await supabase
344
+ .from('plg_scribe_sessions')
345
+ .update({
346
+ transcribed_segment_count: transcribed,
347
+ audio_duration_ms: audioMs,
348
+ transcript_chars: chars,
349
+ updated_at: new Date().toISOString(),
350
+ })
351
+ .eq('id', sessionId)
352
+ }
353
+
354
+ /**
355
+ * `ready` é decidido no SERVIDOR, a partir do manifesto — assim a transição
356
+ * acontece com ou sem browser aberto.
357
+ *
358
+ * `ready` inclui o caso em que alguns segmentos esgotaram as tentativas: uma
359
+ * sessão com 78 de 80 segmentos transcritos é utilizável, e travá-la em
360
+ * `transcribing` para sempre por causa de dois seria o pior dos dois mundos.
361
+ */
362
+ async function maybeMarkReady(supabase: any, sessionId: string): Promise<void> {
363
+ const { data: session } = await supabase
364
+ .from('plg_scribe_sessions')
365
+ .select('status, ended_at')
366
+ .eq('id', sessionId)
367
+ .single()
368
+ if (!session?.ended_at) return
369
+ if (!['uploading', 'transcribing', 'recording', 'paused', 'interrupted'].includes(session.status)) return
370
+
371
+ const { data: segs } = await supabase
372
+ .from('plg_scribe_segments')
373
+ .select('stt_state, stt_attempts, upload_state')
374
+ .eq('session_id', sessionId)
375
+ if (!segs || segs.length === 0) return
376
+
377
+ const outstanding = segs.filter(
378
+ (s: any) =>
379
+ s.upload_state === 'uploaded' &&
380
+ ['pending', 'running', 'failed'].includes(s.stt_state) &&
381
+ (s.stt_attempts ?? 0) < MAX_ATTEMPTS,
382
+ )
383
+ const nextStatus = outstanding.length === 0 ? 'ready' : 'transcribing'
384
+ await supabase
385
+ .from('plg_scribe_sessions')
386
+ .update({ status: nextStatus, updated_at: new Date().toISOString() })
387
+ .eq('id', sessionId)
388
+ }
389
+
390
+ // ---------------------------------------------------------------------------
391
+ // Handler
392
+ // ---------------------------------------------------------------------------
393
+
394
+ Deno.serve(async (req: Request) => {
395
+ if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
396
+
397
+ try {
398
+ const body = await req.json()
399
+ const action = body.action ?? 'drain'
400
+ const sessionId = body.sessionId as string
401
+ if (!sessionId) throw new Error('sessionId é obrigatório')
402
+
403
+ const provider = (body.provider as string) ?? 'deepgram'
404
+ const model = body.model as string | undefined
405
+ const diarize = body.diarize !== false
406
+
407
+ const supabase = createClient(
408
+ Deno.env.get('SUPABASE_URL') ?? '',
409
+ Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '',
410
+ { auth: { persistSession: false } },
411
+ )
412
+
413
+ // A service key contorna a RLS, então a autorização é feita aqui: o JWT do
414
+ // chamador precisa pertencer a um membro do tenant DESTA sessão. Sem esta
415
+ // checagem qualquer usuário logado leria o áudio de qualquer clínica.
416
+ const authHeader = req.headers.get('Authorization') ?? ''
417
+ const jwt = authHeader.replace(/^Bearer\s+/i, '')
418
+ if (!jwt) return json({ error: 'não autenticado' }, 401)
419
+
420
+ const { data: caller } = await supabase.auth.getUser(jwt)
421
+ if (!caller?.user) return json({ error: 'não autenticado' }, 401)
422
+
423
+ const { data: session } = await supabase
424
+ .from('plg_scribe_sessions')
425
+ .select('tenant_id')
426
+ .eq('id', sessionId)
427
+ .single()
428
+ if (!session) return json({ error: 'sessão não encontrada' }, 404)
429
+
430
+ const { data: membership } = await supabase
431
+ .schema('saas_core')
432
+ .from('tenant_members')
433
+ .select('user_id')
434
+ .eq('tenant_id', session.tenant_id)
435
+ .eq('user_id', caller.user.id)
436
+ .maybeSingle()
437
+ if (!membership) return json({ error: 'acesso negado' }, 403)
438
+
439
+ if (action === 'drain') {
440
+ const result = await drainSession(supabase, sessionId, provider, model, diarize)
441
+ return json({ ok: true, ...result })
442
+ }
443
+
444
+ return json({ error: `ação desconhecida: ${action}` }, 400)
445
+ } catch (err) {
446
+ return json({ error: String((err as Error)?.message ?? err) }, 500)
447
+ }
448
+ })
449
+
450
+ function json(payload: unknown, status = 200): Response {
451
+ return new Response(JSON.stringify(payload), {
452
+ status,
453
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
454
+ })
455
+ }
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@fayz-ai/plugin-scribe",
3
+ "fayz": {
4
+ "status": "experimental"
5
+ },
6
+ "version": "0.10.0",
7
+ "description": "Fayz SDK — ambient capture: record a session, transcribe it, generate a narrative document",
8
+ "type": "module",
9
+ "main": "./dist/index.js",
10
+ "module": "./dist/index.js",
11
+ "types": "./dist/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "functions"
21
+ ],
22
+ "peerDependencies": {
23
+ "react": "^18.0.0 || ^19.0.0",
24
+ "react-dom": "^18.0.0 || ^19.0.0"
25
+ },
26
+ "dependencies": {
27
+ "lucide-react": "^0.400.0",
28
+ "zustand": "^4.5.0",
29
+ "@fayz-ai/core": "^0.10.0",
30
+ "@fayz-ai/admin": "^0.10.0",
31
+ "@fayz-ai/ui": "^0.10.0"
32
+ },
33
+ "devDependencies": {
34
+ "@types/react": "^18.3.0",
35
+ "@types/react-dom": "^18.3.0",
36
+ "react": "^18.3.0",
37
+ "react-dom": "^18.3.0",
38
+ "tsup": "^8.2.0",
39
+ "typescript": "^5.5.0",
40
+ "vitest": "^4.1.5"
41
+ },
42
+ "license": "MIT",
43
+ "keywords": [
44
+ "fayz",
45
+ "fayz-plugin",
46
+ "fayz-sdk"
47
+ ],
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
51
+ "scripts": {
52
+ "build": "tsup && tsc --emitDeclarationOnly --declaration --declarationMap --noEmit false",
53
+ "dev": "tsup --watch",
54
+ "typecheck": "tsc --noEmit",
55
+ "test": "vitest run",
56
+ "clean": "rm -rf dist"
57
+ }
58
+ }