neoagent 2.0.0 → 2.0.2

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 (38) hide show
  1. package/.env.example +8 -0
  2. package/docs/configuration.md +3 -0
  3. package/package.json +1 -1
  4. package/server/db/database.js +75 -0
  5. package/server/http/middleware.js +29 -3
  6. package/server/http/routes.js +1 -0
  7. package/server/index.js +97 -6
  8. package/server/public/.last_build_id +1 -1
  9. package/server/public/assets/NOTICES +24298 -26578
  10. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  11. package/server/public/assets/shaders/ink_sparkle.frag +1 -0
  12. package/server/public/assets/shaders/stretch_effect.frag +64 -0
  13. package/server/public/assets/web/icons/Icon-192.png +0 -0
  14. package/server/public/canvaskit/canvaskit.js +91 -90
  15. package/server/public/canvaskit/canvaskit.js.symbols +11577 -11578
  16. package/server/public/canvaskit/canvaskit.wasm +0 -0
  17. package/server/public/canvaskit/chromium/canvaskit.js +92 -91
  18. package/server/public/canvaskit/chromium/canvaskit.js.symbols +10382 -10395
  19. package/server/public/canvaskit/chromium/canvaskit.wasm +0 -0
  20. package/server/public/canvaskit/skwasm.js +95 -89
  21. package/server/public/canvaskit/skwasm.js.symbols +12814 -12146
  22. package/server/public/canvaskit/skwasm.wasm +0 -0
  23. package/server/public/canvaskit/skwasm_heavy.js +95 -89
  24. package/server/public/canvaskit/skwasm_heavy.js.symbols +14435 -13747
  25. package/server/public/canvaskit/skwasm_heavy.wasm +0 -0
  26. package/server/public/canvaskit/wimp.js +136 -0
  27. package/server/public/canvaskit/wimp.js.symbols +11313 -0
  28. package/server/public/canvaskit/wimp.wasm +0 -0
  29. package/server/public/favicon.svg +12 -0
  30. package/server/public/flutter.js +3 -4
  31. package/server/public/flutter_bootstrap.js +5 -6
  32. package/server/public/flutter_service_worker.js +22 -199
  33. package/server/public/index.html +1 -0
  34. package/server/public/main.dart.js +73857 -65543
  35. package/server/routes/recordings.js +113 -0
  36. package/server/services/manager.js +9 -0
  37. package/server/services/recordings/deepgram.js +53 -0
  38. package/server/services/recordings/manager.js +715 -0
@@ -0,0 +1,715 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { v4: uuidv4 } = require('uuid');
6
+
7
+ const db = require('../../db/database');
8
+ const { DATA_DIR } = require('../../../runtime/paths');
9
+ const { sanitizeError } = require('../../utils/security');
10
+ const {
11
+ DEFAULT_LANGUAGE,
12
+ DEFAULT_MODEL,
13
+ isDeepgramConfigured,
14
+ transcribeChunkWithDeepgram,
15
+ } = require('./deepgram');
16
+
17
+ const RECORDINGS_DIR = path.join(DATA_DIR, 'recordings');
18
+ const SESSION_STATUS = {
19
+ recording: 'recording',
20
+ processing: 'processing',
21
+ completed: 'completed',
22
+ failed: 'failed',
23
+ cancelled: 'cancelled',
24
+ };
25
+
26
+ function ensureRecordingDirs() {
27
+ fs.mkdirSync(RECORDINGS_DIR, { recursive: true });
28
+ }
29
+
30
+ class RecordingManager {
31
+ constructor(io) {
32
+ this.io = io;
33
+ ensureRecordingDirs();
34
+ }
35
+
36
+ listSessions(userId, { limit = 24 } = {}) {
37
+ const rows = db.prepare(`
38
+ SELECT *
39
+ FROM recording_sessions
40
+ WHERE user_id = ?
41
+ ORDER BY datetime(created_at) DESC
42
+ LIMIT ?
43
+ `).all(userId, Math.max(1, Math.min(Number(limit) || 24, 100)));
44
+
45
+ return rows.map((row) => this.getSession(userId, row.id));
46
+ }
47
+
48
+ getSession(userId, sessionId) {
49
+ const session = db.prepare(`
50
+ SELECT *
51
+ FROM recording_sessions
52
+ WHERE user_id = ? AND id = ?
53
+ `).get(userId, sessionId);
54
+ if (!session) {
55
+ throw new Error('Recording session not found.');
56
+ }
57
+
58
+ const sources = db.prepare(`
59
+ SELECT *
60
+ FROM recording_sources
61
+ WHERE session_id = ?
62
+ ORDER BY created_at ASC
63
+ `).all(sessionId);
64
+
65
+ const segments = db.prepare(`
66
+ SELECT *
67
+ FROM recording_transcript_segments
68
+ WHERE session_id = ?
69
+ ORDER BY start_ms ASC, id ASC
70
+ `).all(sessionId);
71
+
72
+ return {
73
+ ...this.#mapSession(session),
74
+ sources: sources.map((source) => this.#mapSource(source)),
75
+ transcriptSegments: segments.map((segment) => this.#mapSegment(segment)),
76
+ };
77
+ }
78
+
79
+ createSession(userId, payload = {}) {
80
+ const sessionId = uuidv4();
81
+ const now = new Date().toISOString();
82
+ const platform = typeof payload.platform === 'string' && payload.platform.trim()
83
+ ? payload.platform.trim()
84
+ : 'unknown';
85
+ const sources = this.#normalizeSources(payload.sources, platform);
86
+ const metadata = {
87
+ capturePlan: payload.capturePlan || 'chunked-dual-source',
88
+ screenAnalysisReady: payload.screenAnalysisReady !== false,
89
+ notes: payload.notes || null,
90
+ };
91
+
92
+ const insertSession = db.prepare(`
93
+ INSERT INTO recording_sessions (
94
+ id, user_id, title, platform, status, metadata_json, started_at, created_at, updated_at
95
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
96
+ `);
97
+ const insertSource = db.prepare(`
98
+ INSERT INTO recording_sources (
99
+ id, session_id, source_key, source_kind, media_kind, mime_type, status, metadata_json, created_at, updated_at
100
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
101
+ `);
102
+
103
+ db.transaction(() => {
104
+ insertSession.run(
105
+ sessionId,
106
+ userId,
107
+ this.#resolveTitle(payload.title, platform, now),
108
+ platform,
109
+ SESSION_STATUS.recording,
110
+ JSON.stringify(metadata),
111
+ now,
112
+ now,
113
+ now,
114
+ );
115
+
116
+ for (const source of sources) {
117
+ insertSource.run(
118
+ uuidv4(),
119
+ sessionId,
120
+ source.sourceKey,
121
+ source.sourceKind,
122
+ source.mediaKind,
123
+ source.mimeType,
124
+ SESSION_STATUS.recording,
125
+ JSON.stringify(source.metadata),
126
+ now,
127
+ now,
128
+ );
129
+ }
130
+ })();
131
+
132
+ this.#emitUpdate(userId, sessionId);
133
+ return this.getSession(userId, sessionId);
134
+ }
135
+
136
+ appendChunk(userId, sessionId, metadata = {}, audioBytes) {
137
+ if (!(audioBytes instanceof Buffer) || audioBytes.length === 0) {
138
+ throw new Error('Recording chunk is empty.');
139
+ }
140
+
141
+ const session = db.prepare(`
142
+ SELECT *
143
+ FROM recording_sessions
144
+ WHERE id = ? AND user_id = ?
145
+ `).get(sessionId, userId);
146
+ if (!session) {
147
+ throw new Error('Recording session not found.');
148
+ }
149
+ if (![SESSION_STATUS.recording, SESSION_STATUS.processing].includes(session.status)) {
150
+ throw new Error('Recording session is not accepting more chunks.');
151
+ }
152
+
153
+ const sourceKey = `${metadata.sourceKey || ''}`.trim();
154
+ if (!sourceKey) {
155
+ throw new Error('sourceKey is required.');
156
+ }
157
+ const source = db.prepare(`
158
+ SELECT *
159
+ FROM recording_sources
160
+ WHERE session_id = ? AND source_key = ?
161
+ `).get(sessionId, sourceKey);
162
+ if (!source) {
163
+ throw new Error(`Unknown recording source: ${sourceKey}`);
164
+ }
165
+
166
+ const sequenceIndex = Number(metadata.sequenceIndex);
167
+ if (!Number.isInteger(sequenceIndex) || sequenceIndex < 0) {
168
+ throw new Error('sequenceIndex must be a non-negative integer.');
169
+ }
170
+
171
+ const existing = db.prepare(`
172
+ SELECT id
173
+ FROM recording_chunks
174
+ WHERE source_id = ? AND sequence_index = ?
175
+ `).get(source.id, sequenceIndex);
176
+ if (existing) {
177
+ return {
178
+ duplicate: true,
179
+ accepted: false,
180
+ sessionId,
181
+ sourceKey,
182
+ sequenceIndex,
183
+ };
184
+ }
185
+
186
+ const mimeType = `${metadata.mimeType || source.mime_type || 'application/octet-stream'}`.trim();
187
+ const startMs = Math.max(0, Number(metadata.startMs) || 0);
188
+ const endMs = Math.max(startMs, Number(metadata.endMs) || startMs);
189
+ const extension = this.#extensionForMime(mimeType);
190
+ const fileDir = path.join(RECORDINGS_DIR, `user-${userId}`, sessionId, sourceKey);
191
+ fs.mkdirSync(fileDir, { recursive: true });
192
+ const filePath = path.join(fileDir, `${String(sequenceIndex).padStart(6, '0')}${extension}`);
193
+ const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
194
+ fs.writeFileSync(tempPath, audioBytes);
195
+ fs.renameSync(tempPath, filePath);
196
+
197
+ db.transaction(() => {
198
+ db.prepare(`
199
+ INSERT INTO recording_chunks (
200
+ source_id, sequence_index, start_ms, end_ms, byte_count, mime_type, file_path
201
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
202
+ `).run(
203
+ source.id,
204
+ sequenceIndex,
205
+ startMs,
206
+ endMs,
207
+ audioBytes.length,
208
+ mimeType,
209
+ filePath,
210
+ );
211
+
212
+ db.prepare(`
213
+ UPDATE recording_sources
214
+ SET
215
+ mime_type = COALESCE(?, mime_type),
216
+ chunk_count = chunk_count + 1,
217
+ bytes_received = bytes_received + ?,
218
+ duration_ms = MAX(duration_ms, ?),
219
+ updated_at = ?
220
+ WHERE id = ?
221
+ `).run(
222
+ mimeType,
223
+ audioBytes.length,
224
+ endMs,
225
+ new Date().toISOString(),
226
+ source.id,
227
+ );
228
+
229
+ db.prepare(`
230
+ UPDATE recording_sessions
231
+ SET updated_at = ?
232
+ WHERE id = ?
233
+ `).run(new Date().toISOString(), sessionId);
234
+ })();
235
+
236
+ return {
237
+ duplicate: false,
238
+ accepted: true,
239
+ sessionId,
240
+ sourceKey,
241
+ sequenceIndex,
242
+ bytesReceived: audioBytes.length,
243
+ };
244
+ }
245
+
246
+ finalizeSession(userId, sessionId, options = {}) {
247
+ const session = db.prepare(`
248
+ SELECT *
249
+ FROM recording_sessions
250
+ WHERE id = ? AND user_id = ?
251
+ `).get(sessionId, userId);
252
+ if (!session) {
253
+ throw new Error('Recording session not found.');
254
+ }
255
+
256
+ const stopReason = `${options.stopReason || 'stopped'}`.trim();
257
+ const mergedMetadata = {
258
+ ...this.#parseJson(session.metadata_json, {}),
259
+ stopReason,
260
+ };
261
+ const chunkCount = db.prepare(`
262
+ SELECT COUNT(*) AS count
263
+ FROM recording_chunks c
264
+ INNER JOIN recording_sources s ON s.id = c.source_id
265
+ WHERE s.session_id = ?
266
+ `).get(sessionId).count;
267
+ const nextStatus = chunkCount > 0 ? SESSION_STATUS.processing : SESSION_STATUS.cancelled;
268
+ const now = new Date().toISOString();
269
+
270
+ db.transaction(() => {
271
+ db.prepare(`
272
+ UPDATE recording_sessions
273
+ SET
274
+ status = ?,
275
+ ended_at = COALESCE(ended_at, ?),
276
+ metadata_json = ?,
277
+ updated_at = ?
278
+ WHERE id = ?
279
+ `).run(nextStatus, now, JSON.stringify(mergedMetadata), now, sessionId);
280
+
281
+ db.prepare(`
282
+ UPDATE recording_sources
283
+ SET status = CASE WHEN chunk_count > 0 THEN ? ELSE ? END, updated_at = ?
284
+ WHERE session_id = ?
285
+ `).run(nextStatus, nextStatus === SESSION_STATUS.processing ? SESSION_STATUS.cancelled : nextStatus, now, sessionId);
286
+ })();
287
+
288
+ this.#emitUpdate(userId, sessionId);
289
+
290
+ if (nextStatus === SESSION_STATUS.processing) {
291
+ this.processSession(userId, sessionId).catch((error) => {
292
+ console.error('[Recordings] Processing failed:', sanitizeError(error));
293
+ });
294
+ }
295
+
296
+ return this.getSession(userId, sessionId);
297
+ }
298
+
299
+ async retrySession(userId, sessionId) {
300
+ const session = db.prepare(`
301
+ SELECT *
302
+ FROM recording_sessions
303
+ WHERE id = ? AND user_id = ?
304
+ `).get(sessionId, userId);
305
+ if (!session) {
306
+ throw new Error('Recording session not found.');
307
+ }
308
+ if (!isDeepgramConfigured()) {
309
+ throw new Error('DEEPGRAM_API_KEY is not configured.');
310
+ }
311
+
312
+ db.prepare(`
313
+ UPDATE recording_sessions
314
+ SET status = ?, last_error = NULL, updated_at = ?
315
+ WHERE id = ?
316
+ `).run(SESSION_STATUS.processing, new Date().toISOString(), sessionId);
317
+ this.#emitUpdate(userId, sessionId);
318
+
319
+ await this.processSession(userId, sessionId);
320
+ return this.getSession(userId, sessionId);
321
+ }
322
+
323
+ async resumePendingSessions() {
324
+ const rows = db.prepare(`
325
+ SELECT id, user_id
326
+ FROM recording_sessions
327
+ WHERE status = ?
328
+ ORDER BY created_at ASC
329
+ `).all(SESSION_STATUS.processing);
330
+
331
+ for (const row of rows) {
332
+ try {
333
+ await this.processSession(row.user_id, row.id);
334
+ } catch (error) {
335
+ console.error('[Recordings] Resume failed:', sanitizeError(error));
336
+ }
337
+ }
338
+ }
339
+
340
+ async processSession(userId, sessionId) {
341
+ if (!isDeepgramConfigured()) {
342
+ throw new Error('DEEPGRAM_API_KEY is not configured.');
343
+ }
344
+
345
+ const session = db.prepare(`
346
+ SELECT *
347
+ FROM recording_sessions
348
+ WHERE id = ? AND user_id = ?
349
+ `).get(sessionId, userId);
350
+ if (!session) {
351
+ throw new Error('Recording session not found.');
352
+ }
353
+
354
+ const sources = db.prepare(`
355
+ SELECT *
356
+ FROM recording_sources
357
+ WHERE session_id = ?
358
+ ORDER BY created_at ASC
359
+ `).all(sessionId);
360
+ if (sources.length == 0) {
361
+ throw new Error('Recording session has no sources.');
362
+ }
363
+
364
+ db.transaction(() => {
365
+ db.prepare(`
366
+ DELETE FROM recording_transcript_segments
367
+ WHERE session_id = ?
368
+ `).run(sessionId);
369
+ db.prepare(`
370
+ UPDATE recording_sources
371
+ SET status = ?, updated_at = ?
372
+ WHERE session_id = ?
373
+ `).run(SESSION_STATUS.processing, new Date().toISOString(), sessionId);
374
+ })();
375
+
376
+ const collectedSegments = [];
377
+ let maxDuration = 0;
378
+
379
+ try {
380
+ for (const source of sources) {
381
+ const chunks = db.prepare(`
382
+ SELECT *
383
+ FROM recording_chunks
384
+ WHERE source_id = ?
385
+ ORDER BY sequence_index ASC
386
+ `).all(source.id);
387
+
388
+ if (chunks.length === 0) {
389
+ continue;
390
+ }
391
+
392
+ this.#assertSequentialChunks(source.source_key, chunks);
393
+ const sourceSegments = await this.#transcribeSourceChunks(source, chunks);
394
+ maxDuration = Math.max(
395
+ maxDuration,
396
+ ...sourceSegments.map((segment) => Number(segment.endMs) || 0),
397
+ Number(source.duration_ms) || 0,
398
+ );
399
+ collectedSegments.push(...sourceSegments);
400
+
401
+ db.prepare(`
402
+ UPDATE recording_sources
403
+ SET status = ?, duration_ms = ?, updated_at = ?
404
+ WHERE id = ?
405
+ `).run(
406
+ SESSION_STATUS.completed,
407
+ Math.max(
408
+ Number(source.duration_ms) || 0,
409
+ ...sourceSegments.map((segment) => Number(segment.endMs) || 0),
410
+ ),
411
+ new Date().toISOString(),
412
+ source.id,
413
+ );
414
+ }
415
+
416
+ const insertSegment = db.prepare(`
417
+ INSERT INTO recording_transcript_segments (
418
+ session_id, source_id, source_key, speaker, text, start_ms, end_ms, confidence, words_json
419
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
420
+ `);
421
+ const ordered = collectedSegments.sort((a, b) => {
422
+ if (a.startMs !== b.startMs) {
423
+ return a.startMs - b.startMs;
424
+ }
425
+ return a.sourceKey.localeCompare(b.sourceKey);
426
+ });
427
+
428
+ db.transaction(() => {
429
+ for (const segment of ordered) {
430
+ insertSegment.run(
431
+ sessionId,
432
+ segment.sourceId,
433
+ segment.sourceKey,
434
+ segment.speaker,
435
+ segment.text,
436
+ segment.startMs,
437
+ segment.endMs,
438
+ segment.confidence,
439
+ JSON.stringify(segment.words),
440
+ );
441
+ }
442
+ })();
443
+
444
+ const transcriptText = ordered
445
+ .map((segment) => `[${this.#formatTimestamp(segment.startMs)}] ${segment.text}`)
446
+ .join('\n');
447
+ db.prepare(`
448
+ UPDATE recording_sessions
449
+ SET
450
+ status = ?,
451
+ transcript_text = ?,
452
+ transcript_language = ?,
453
+ transcript_model = ?,
454
+ duration_ms = ?,
455
+ last_error = NULL,
456
+ updated_at = ?,
457
+ ended_at = COALESCE(ended_at, ?)
458
+ WHERE id = ?
459
+ `).run(
460
+ SESSION_STATUS.completed,
461
+ transcriptText,
462
+ DEFAULT_LANGUAGE,
463
+ DEFAULT_MODEL,
464
+ maxDuration,
465
+ new Date().toISOString(),
466
+ new Date().toISOString(),
467
+ sessionId,
468
+ );
469
+ } catch (error) {
470
+ db.prepare(`
471
+ UPDATE recording_sessions
472
+ SET status = ?, last_error = ?, updated_at = ?
473
+ WHERE id = ?
474
+ `).run(
475
+ SESSION_STATUS.failed,
476
+ sanitizeError(error),
477
+ new Date().toISOString(),
478
+ sessionId,
479
+ );
480
+ db.prepare(`
481
+ UPDATE recording_sources
482
+ SET status = ?, updated_at = ?
483
+ WHERE session_id = ?
484
+ `).run(SESSION_STATUS.failed, new Date().toISOString(), sessionId);
485
+ this.#emitUpdate(userId, sessionId);
486
+ throw error;
487
+ }
488
+
489
+ this.#emitUpdate(userId, sessionId);
490
+ return this.getSession(userId, sessionId);
491
+ }
492
+
493
+ async #transcribeSourceChunks(source, chunks) {
494
+ const segments = [];
495
+
496
+ for (const chunk of chunks) {
497
+ const audioBytes = fs.readFileSync(chunk.file_path);
498
+ const payload = await transcribeChunkWithDeepgram({
499
+ audioBytes,
500
+ mimeType: chunk.mime_type || source.mime_type,
501
+ detectLanguage: DEFAULT_LANGUAGE,
502
+ });
503
+ segments.push(...this.#extractSegments(source, chunk, payload));
504
+ }
505
+
506
+ return segments;
507
+ }
508
+
509
+ #extractSegments(source, chunk, payload) {
510
+ const results = payload?.results || {};
511
+ const channels = Array.isArray(results.channels) ? results.channels : [];
512
+ const alternative = channels[0]?.alternatives?.[0] || {};
513
+ const utterances = Array.isArray(results.utterances) ? results.utterances : [];
514
+ const words = Array.isArray(alternative.words) ? alternative.words : [];
515
+ const chunkStartMs = Number(chunk.start_ms) || 0;
516
+ const chunkEndMs = Number(chunk.end_ms) || chunkStartMs;
517
+
518
+ if (utterances.length > 0) {
519
+ return utterances
520
+ .map((utterance, index) => {
521
+ const startMs = chunkStartMs + Math.max(0, Math.round((Number(utterance.start) || 0) * 1000));
522
+ const endMs = chunkStartMs + Math.max(0, Math.round((Number(utterance.end) || 0) * 1000));
523
+ return {
524
+ sourceId: source.id,
525
+ sourceKey: source.source_key,
526
+ speaker: source.source_kind,
527
+ text: `${utterance.transcript || ''}`.trim(),
528
+ startMs,
529
+ endMs: Math.max(startMs, endMs),
530
+ confidence: Number(utterance.confidence) || null,
531
+ words: Array.isArray(utterance.words) ? utterance.words : [],
532
+ index,
533
+ };
534
+ })
535
+ .filter((item) => item.text.length > 0);
536
+ }
537
+
538
+ const transcript = `${alternative.transcript || ''}`.trim();
539
+ if (!transcript) {
540
+ return [];
541
+ }
542
+
543
+ return [
544
+ {
545
+ sourceId: source.id,
546
+ sourceKey: source.source_key,
547
+ speaker: source.source_kind,
548
+ text: transcript,
549
+ startMs: chunkStartMs,
550
+ endMs: Math.max(chunkStartMs, chunkEndMs),
551
+ confidence: Number(alternative.confidence) || null,
552
+ words,
553
+ },
554
+ ];
555
+ }
556
+
557
+ #assertSequentialChunks(sourceKey, chunks) {
558
+ for (let index = 0; index < chunks.length; index += 1) {
559
+ if (Number(chunks[index].sequence_index) !== index) {
560
+ throw new Error(`Recording source "${sourceKey}" is missing chunk ${index}.`);
561
+ }
562
+ }
563
+ }
564
+
565
+ #resolveTitle(title, platform, nowIso) {
566
+ if (typeof title === 'string' && title.trim()) {
567
+ return title.trim().slice(0, 160);
568
+ }
569
+ const stamp = new Date(nowIso).toISOString().replace('T', ' ').slice(0, 16);
570
+ if (platform === 'web') {
571
+ return `Screen + mic recording ${stamp}`;
572
+ }
573
+ if (platform === 'android') {
574
+ return `Background microphone recording ${stamp}`;
575
+ }
576
+ return `Recording ${stamp}`;
577
+ }
578
+
579
+ #normalizeSources(rawSources, platform) {
580
+ const fallback = platform === 'android'
581
+ ? [
582
+ {
583
+ sourceKey: 'microphone',
584
+ sourceKind: 'microphone',
585
+ mediaKind: 'audio',
586
+ mimeType: 'audio/wav',
587
+ metadata: { backgroundCapable: true },
588
+ },
589
+ ]
590
+ : [
591
+ {
592
+ sourceKey: 'screen',
593
+ sourceKind: 'screen-share',
594
+ mediaKind: 'video',
595
+ mimeType: 'video/webm',
596
+ metadata: { analysisReady: true },
597
+ },
598
+ {
599
+ sourceKey: 'microphone',
600
+ sourceKind: 'microphone',
601
+ mediaKind: 'audio',
602
+ mimeType: 'audio/webm',
603
+ metadata: {},
604
+ },
605
+ ];
606
+
607
+ const inputs = Array.isArray(rawSources) && rawSources.length > 0 ? rawSources : fallback;
608
+ const seen = new Set();
609
+
610
+ return inputs.map((item, index) => {
611
+ const sourceKey = `${item?.sourceKey || item?.id || `source-${index}`}`.trim().toLowerCase();
612
+ if (!sourceKey) {
613
+ throw new Error('Every recording source needs a sourceKey.');
614
+ }
615
+ if (seen.has(sourceKey)) {
616
+ throw new Error(`Duplicate recording source: ${sourceKey}`);
617
+ }
618
+ seen.add(sourceKey);
619
+ return {
620
+ sourceKey,
621
+ sourceKind: `${item?.sourceKind || sourceKey}`.trim().toLowerCase(),
622
+ mediaKind: `${item?.mediaKind || 'audio'}`.trim().toLowerCase(),
623
+ mimeType: `${item?.mimeType || 'application/octet-stream'}`.trim().toLowerCase(),
624
+ metadata: item?.metadata && typeof item.metadata === 'object'
625
+ ? item.metadata
626
+ : {},
627
+ };
628
+ });
629
+ }
630
+
631
+ #mapSession(row) {
632
+ const metadata = this.#parseJson(row.metadata_json, {});
633
+ return {
634
+ id: row.id,
635
+ title: row.title || 'Recording',
636
+ platform: row.platform || 'unknown',
637
+ status: row.status || SESSION_STATUS.recording,
638
+ transcriptText: row.transcript_text || '',
639
+ transcriptLanguage: row.transcript_language || DEFAULT_LANGUAGE,
640
+ transcriptModel: row.transcript_model || DEFAULT_MODEL,
641
+ startedAt: row.started_at,
642
+ endedAt: row.ended_at,
643
+ durationMs: Number(row.duration_ms) || 0,
644
+ lastError: row.last_error,
645
+ metadata,
646
+ sourceCount: Number(
647
+ db.prepare('SELECT COUNT(*) AS count FROM recording_sources WHERE session_id = ?').get(row.id).count,
648
+ ) || 0,
649
+ };
650
+ }
651
+
652
+ #mapSource(row) {
653
+ return {
654
+ id: row.id,
655
+ sourceKey: row.source_key,
656
+ sourceKind: row.source_kind,
657
+ mediaKind: row.media_kind,
658
+ mimeType: row.mime_type,
659
+ status: row.status,
660
+ chunkCount: Number(row.chunk_count) || 0,
661
+ bytesReceived: Number(row.bytes_received) || 0,
662
+ durationMs: Number(row.duration_ms) || 0,
663
+ metadata: this.#parseJson(row.metadata_json, {}),
664
+ };
665
+ }
666
+
667
+ #mapSegment(row) {
668
+ return {
669
+ id: Number(row.id),
670
+ sourceKey: row.source_key,
671
+ speaker: row.speaker || row.source_key || 'source',
672
+ text: row.text || '',
673
+ startMs: Number(row.start_ms) || 0,
674
+ endMs: Number(row.end_ms) || 0,
675
+ confidence: row.confidence == null ? null : Number(row.confidence),
676
+ words: this.#parseJson(row.words_json, []),
677
+ };
678
+ }
679
+
680
+ #emitUpdate(userId, sessionId) {
681
+ this.io?.to?.(`user:${userId}`)?.emit('recordings:updated', { sessionId });
682
+ }
683
+
684
+ #extensionForMime(mimeType) {
685
+ if (/wav/i.test(mimeType)) return '.wav';
686
+ if (/webm/i.test(mimeType)) return '.webm';
687
+ if (/mp4|m4a/i.test(mimeType)) return '.m4a';
688
+ if (/ogg|opus/i.test(mimeType)) return '.ogg';
689
+ return '.bin';
690
+ }
691
+
692
+ #formatTimestamp(ms) {
693
+ const totalSeconds = Math.max(0, Math.floor((Number(ms) || 0) / 1000));
694
+ const hours = Math.floor(totalSeconds / 3600);
695
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
696
+ const seconds = totalSeconds % 60;
697
+ if (hours > 0) {
698
+ return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
699
+ }
700
+ return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
701
+ }
702
+
703
+ #parseJson(value, fallback) {
704
+ try {
705
+ return value ? JSON.parse(value) : fallback;
706
+ } catch {
707
+ return fallback;
708
+ }
709
+ }
710
+ }
711
+
712
+ module.exports = {
713
+ RecordingManager,
714
+ SESSION_STATUS,
715
+ };