neoagent 3.1.1-beta.9 → 3.2.1-beta.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 (151) hide show
  1. package/.env.example +2 -2
  2. package/README.md +5 -1
  3. package/docs/architecture.md +1 -1
  4. package/docs/benchmarking.md +68 -87
  5. package/docs/capabilities.md +1 -1
  6. package/docs/clients-and-devices.md +3 -4
  7. package/docs/configuration.md +1 -1
  8. package/docs/devices.md +1 -2
  9. package/docs/health.md +24 -0
  10. package/docs/index.md +2 -2
  11. package/docs/integrations-architecture.md +6 -0
  12. package/docs/integrations.md +15 -1
  13. package/docs/models.md +1 -1
  14. package/docs/persistence.md +1 -1
  15. package/docs/why-neoagent.md +1 -1
  16. package/flutter_app/README.md +0 -5
  17. package/flutter_app/android/app/src/main/AndroidManifest.xml +0 -14
  18. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/MainActivity.kt +0 -98
  19. package/flutter_app/lib/main.dart +0 -6
  20. package/flutter_app/lib/main_app_shell.dart +0 -2
  21. package/flutter_app/lib/main_controller.dart +2 -867
  22. package/flutter_app/lib/main_devices.dart +0 -73
  23. package/flutter_app/lib/main_integrations.dart +339 -187
  24. package/flutter_app/lib/main_launcher.dart +2 -212
  25. package/flutter_app/lib/main_models.dart +0 -259
  26. package/flutter_app/lib/main_navigation.dart +2 -14
  27. package/flutter_app/lib/main_operations.dart +875 -383
  28. package/flutter_app/lib/main_runtime.dart +9 -208
  29. package/flutter_app/lib/main_settings.dart +17 -199
  30. package/flutter_app/lib/main_shared.dart +20 -591
  31. package/flutter_app/lib/main_timeline.dart +26 -122
  32. package/flutter_app/lib/main_voice_assistant.dart +0 -5
  33. package/flutter_app/lib/src/backend_client.dart +0 -102
  34. package/flutter_app/lib/src/desktop_companion_io.dart +1 -65
  35. package/flutter_app/lib/src/desktop_companion_stub.dart +0 -12
  36. package/flutter_app/linux/flutter/generated_plugin_registrant.cc +0 -4
  37. package/flutter_app/linux/flutter/generated_plugins.cmake +0 -1
  38. package/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +0 -2
  39. package/flutter_app/macos/Podfile.lock +56 -5
  40. package/flutter_app/pubspec.lock +0 -7
  41. package/flutter_app/pubspec.yaml +0 -2
  42. package/flutter_app/web/index.html +174 -0
  43. package/flutter_app/windows/flutter/generated_plugin_registrant.cc +0 -3
  44. package/flutter_app/windows/flutter/generated_plugins.cmake +0 -1
  45. package/landing/index.html +0 -2
  46. package/lib/manager.js +1 -1
  47. package/lib/schema_migrations.js +75 -0
  48. package/package.json +1 -4
  49. package/server/db/database.js +1 -133
  50. package/server/http/middleware.js +2 -16
  51. package/server/http/routes.js +0 -2
  52. package/server/public/.last_build_id +1 -1
  53. package/server/public/assets/NOTICES +0 -25
  54. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  55. package/server/public/flutter_bootstrap.js +2 -2
  56. package/server/public/index.html +174 -0
  57. package/server/public/main.dart.js +92036 -91145
  58. package/server/routes/auth.js +11 -2
  59. package/server/routes/settings.js +0 -5
  60. package/server/routes/voice_assistant.js +1 -128
  61. package/server/services/account/erasure.js +0 -3
  62. package/server/services/ai/deliverables/artifact_helpers.js +1 -1
  63. package/server/services/ai/loop/conversation_loop.js +6 -2
  64. package/server/services/ai/loop/tool_dispatch.js +0 -3
  65. package/server/services/ai/messagingFallback.js +1 -1
  66. package/server/services/ai/settings.js +0 -6
  67. package/server/services/ai/toolEvidence.js +1 -4
  68. package/server/services/ai/tools.js +0 -234
  69. package/server/services/desktop/auth.js +0 -3
  70. package/server/services/desktop/registry.js +2 -50
  71. package/server/services/integrations/neoarchive/provider.js +616 -0
  72. package/server/services/integrations/neorecall/client.js +140 -0
  73. package/server/services/integrations/neorecall/constants.js +87 -0
  74. package/server/services/integrations/neorecall/provider.js +163 -0
  75. package/server/services/integrations/registry.js +4 -2
  76. package/server/services/manager.js +0 -22
  77. package/server/services/memory/manager.js +44 -19
  78. package/server/services/memory/retrieval_reasoning.js +36 -14
  79. package/server/services/messaging/formatting_guides.js +1 -0
  80. package/server/services/messaging/whatsapp.js +18 -0
  81. package/server/services/security/tool_categories.js +0 -5
  82. package/server/services/social_video/service.js +2 -20
  83. package/server/services/tasks/adapters/index.js +1 -1
  84. package/server/services/tasks/integration_runtime.js +3 -67
  85. package/server/services/tasks/runtime.js +1 -6
  86. package/server/services/tasks/task_repository.js +6 -13
  87. package/server/services/timeline/service.js +0 -224
  88. package/server/services/voice/message.js +0 -24
  89. package/server/services/voice/providers.js +42 -1
  90. package/server/services/voice/runtimeManager.js +9 -5
  91. package/server/services/voice/turnRunner.js +0 -22
  92. package/server/services/wearable/service.js +0 -5
  93. package/docs/recordings-and-health.md +0 -41
  94. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingBootReceiver.kt +0 -21
  95. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingForegroundService.kt +0 -586
  96. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingStateStore.kt +0 -78
  97. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingUploadClient.kt +0 -104
  98. package/flutter_app/lib/main_recordings.dart +0 -920
  99. package/flutter_app/lib/src/desktop_ocr_bridge.dart +0 -2
  100. package/flutter_app/lib/src/desktop_ocr_bridge_io.dart +0 -125
  101. package/flutter_app/lib/src/desktop_ocr_bridge_stub.dart +0 -30
  102. package/flutter_app/lib/src/desktop_passive_history.dart +0 -332
  103. package/flutter_app/lib/src/recording_bridge.dart +0 -232
  104. package/flutter_app/lib/src/recording_bridge_io.dart +0 -1027
  105. package/flutter_app/lib/src/recording_bridge_stub.dart +0 -120
  106. package/flutter_app/lib/src/recording_bridge_web.dart +0 -702
  107. package/flutter_app/lib/src/recording_chunk_queue.dart +0 -149
  108. package/flutter_app/lib/src/recording_chunk_queue_io.dart +0 -182
  109. package/flutter_app/lib/src/recording_payloads.dart +0 -95
  110. package/flutter_app/third_party/desktop_audio_capture/LICENSE +0 -21
  111. package/flutter_app/third_party/desktop_audio_capture/README.md +0 -262
  112. package/flutter_app/third_party/desktop_audio_capture/lib/audio_capture.dart +0 -65
  113. package/flutter_app/third_party/desktop_audio_capture/lib/config/mic_audio_config.dart +0 -153
  114. package/flutter_app/third_party/desktop_audio_capture/lib/config/system_adudio_config.dart +0 -110
  115. package/flutter_app/third_party/desktop_audio_capture/lib/mic/mic_audio_capture.dart +0 -461
  116. package/flutter_app/third_party/desktop_audio_capture/lib/model/audio_status.dart +0 -91
  117. package/flutter_app/third_party/desktop_audio_capture/lib/model/decibel_data.dart +0 -106
  118. package/flutter_app/third_party/desktop_audio_capture/lib/model/input_device_type.dart +0 -219
  119. package/flutter_app/third_party/desktop_audio_capture/lib/system/system_audio_capture.dart +0 -336
  120. package/flutter_app/third_party/desktop_audio_capture/linux/CMakeLists.txt +0 -101
  121. package/flutter_app/third_party/desktop_audio_capture/linux/audio_capture_plugin.cc +0 -692
  122. package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/audio_capture_plugin.h +0 -35
  123. package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/mic_capture_plugin.h +0 -36
  124. package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/audio_capture_plugin.h +0 -32
  125. package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/mic_capture_plugin.h +0 -32
  126. package/flutter_app/third_party/desktop_audio_capture/linux/mic_capture_plugin.cc +0 -878
  127. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/AudioCapturePlugin.swift +0 -27
  128. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/MicCapturePlugin.swift +0 -1172
  129. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/SystemCapturePlugin.swift +0 -655
  130. package/flutter_app/third_party/desktop_audio_capture/macos/Resources/PrivacyInfo.xcprivacy +0 -12
  131. package/flutter_app/third_party/desktop_audio_capture/macos/desktop_audio_capture.podspec +0 -30
  132. package/flutter_app/third_party/desktop_audio_capture/pubspec.yaml +0 -87
  133. package/flutter_app/third_party/desktop_audio_capture/windows/CMakeLists.txt +0 -105
  134. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.cpp +0 -80
  135. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.h +0 -31
  136. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin_c_api.cpp +0 -12
  137. package/flutter_app/third_party/desktop_audio_capture/windows/include/audio_capture/audio_capture_plugin_c_api.h +0 -23
  138. package/flutter_app/third_party/desktop_audio_capture/windows/include/desktop_audio_capture/audio_capture_plugin.h +0 -25
  139. package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.cpp +0 -1117
  140. package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.h +0 -115
  141. package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.cpp +0 -777
  142. package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.h +0 -87
  143. package/server/routes/recordings.js +0 -335
  144. package/server/routes/screenHistory.js +0 -190
  145. package/server/services/ai/recordingInsights.js +0 -105
  146. package/server/services/integrations/neomail/provider.js +0 -993
  147. package/server/services/recordings/deepgram.js +0 -58
  148. package/server/services/recordings/manager.js +0 -1273
  149. package/server/services/tasks/adapters/neomail_email_received.js +0 -46
  150. package/server/services/voice/screenshotContext.js +0 -73
  151. package/server/services/voice/turnCoordinator.js +0 -51
@@ -1,41 +0,0 @@
1
- # Recordings and health
2
-
3
- Recordings and health data are optional features of the NeoAgent clients. Both
4
- store their processed data on the NeoAgent backend.
5
-
6
- ## Recordings
7
-
8
- The web client can capture microphone and screen audio. The Android client can
9
- record microphone audio through a foreground service.
10
-
11
- Recordings upload in chunks and move through recording, processing, completed,
12
- failed, or cancelled states. When Deepgram is configured, NeoAgent transcribes
13
- the audio and stores timestamped segments that can be searched from the
14
- operator interface or by the agent.
15
-
16
- Optional recording insights can produce summaries, action items, and event
17
- suggestions after transcription.
18
-
19
- The agent can list recordings, inspect a session, and search transcripts. It
20
- can also extract transcripts and metadata from supported public social-video
21
- URLs when the required host tool is installed.
22
-
23
- ## Health Connect
24
-
25
- The Android app can read Health Connect records after the user grants Android
26
- permissions. Supported data includes steps, heart rate, sleep, exercise, and
27
- weight.
28
-
29
- The app uploads granted records to the user's NeoAgent backend. Stored metrics
30
- appear in **Health** and are available to the owning agent through the health
31
- tool.
32
-
33
- Health Connect access can be revoked in Android settings. Revoking access stops
34
- future reads but does not remove records already uploaded to NeoAgent; delete
35
- stored server data separately when required.
36
-
37
- ## Privacy
38
-
39
- Audio, transcripts, and health records are sensitive personal data. Protect the
40
- server account, use HTTPS for remote clients, restrict agents and integrations,
41
- and include these data stores in backup and deletion procedures.
@@ -1,21 +0,0 @@
1
- package com.neoagent.flutter_app.recording
2
-
3
- import android.content.BroadcastReceiver
4
- import android.content.Context
5
- import android.content.Intent
6
- import androidx.core.content.ContextCompat
7
-
8
- class RecordingBootReceiver : BroadcastReceiver() {
9
- override fun onReceive(context: Context, intent: Intent?) {
10
- if (intent?.action != Intent.ACTION_BOOT_COMPLETED) {
11
- return
12
- }
13
- val store = RecordingStateStore(context)
14
- val config = store.loadConfig() ?: return
15
- if (!config.active || config.paused) {
16
- return
17
- }
18
- val serviceIntent = RecordingForegroundService.buildRestoreIntent(context)
19
- ContextCompat.startForegroundService(context, serviceIntent)
20
- }
21
- }
@@ -1,586 +0,0 @@
1
- package com.neoagent.flutter_app.recording
2
-
3
- import android.Manifest
4
- import android.app.Notification
5
- import android.app.NotificationChannel
6
- import android.app.NotificationManager
7
- import android.app.PendingIntent
8
- import android.app.Service
9
- import android.content.Context
10
- import android.content.Intent
11
- import android.content.pm.PackageManager
12
- import android.media.AudioFormat
13
- import android.media.AudioRecord
14
- import android.media.MediaRecorder
15
- import android.media.audiofx.AutomaticGainControl
16
- import android.media.audiofx.NoiseSuppressor
17
- import android.os.Build
18
- import android.os.IBinder
19
- import androidx.core.app.NotificationCompat
20
- import androidx.core.content.ContextCompat
21
- import kotlinx.coroutines.CoroutineScope
22
- import kotlinx.coroutines.Dispatchers
23
- import kotlinx.coroutines.Job
24
- import kotlinx.coroutines.SupervisorJob
25
- import kotlinx.coroutines.cancel
26
- import kotlinx.coroutines.delay
27
- import kotlinx.coroutines.isActive
28
- import kotlinx.coroutines.launch
29
- import kotlinx.coroutines.sync.Mutex
30
- import kotlinx.coroutines.sync.withLock
31
- import org.json.JSONObject
32
- import java.io.ByteArrayOutputStream
33
- import java.io.File
34
- import java.io.OutputStreamWriter
35
- import java.time.Instant
36
-
37
- class RecordingForegroundService : Service() {
38
- private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
39
- private val stateStore by lazy { RecordingStateStore(this) }
40
- private val uploadClient = RecordingUploadClient()
41
- private val uploadMutex = Mutex()
42
-
43
- private var config: RecordingConfig? = null
44
- private var audioRecord: AudioRecord? = null
45
- private var captureJob: Job? = null
46
- private var noiseSuppressor: NoiseSuppressor? = null
47
- private var automaticGainControl: AutomaticGainControl? = null
48
-
49
- override fun onBind(intent: Intent?): IBinder? = null
50
-
51
- override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
52
- when (intent?.action) {
53
- ACTION_START -> startNewRecording(intent)
54
- ACTION_RESTORE -> restoreRecording()
55
- ACTION_PAUSE -> pauseRecording()
56
- ACTION_RESUME -> resumeRecording()
57
- ACTION_STOP -> stopRecording(finalize = true)
58
- }
59
- return START_STICKY
60
- }
61
-
62
- override fun onDestroy() {
63
- captureJob?.cancel()
64
- stopRecorder()
65
- serviceScope.cancel()
66
- super.onDestroy()
67
- }
68
-
69
- private fun startNewRecording(intent: Intent) {
70
- ensureMicPermission()
71
- val backendUrl = intent.getStringExtra(EXTRA_BACKEND_URL).orEmpty()
72
- val sessionCookie = intent.getStringExtra(EXTRA_SESSION_COOKIE).orEmpty()
73
- val sessionId = intent.getStringExtra(EXTRA_SESSION_ID).orEmpty()
74
- require(backendUrl.isNotBlank()) { "Backend URL is required." }
75
- require(sessionCookie.isNotBlank()) { "Session cookie is required." }
76
- require(sessionId.isNotBlank()) { "Session ID is required." }
77
-
78
- config = RecordingConfig(
79
- backendUrl = backendUrl,
80
- sessionCookie = sessionCookie,
81
- sessionId = sessionId,
82
- active = true,
83
- paused = false,
84
- nextSequence = 0,
85
- capturedAudioMs = 0L,
86
- startedAt = Instant.now().toString(),
87
- errorMessage = null,
88
- )
89
- stateStore.saveConfig(config!!)
90
- startForegroundServiceUi()
91
- serviceScope.launch {
92
- drainPendingUploads()
93
- startCaptureLoop()
94
- }
95
- }
96
-
97
- private fun restoreRecording() {
98
- ensureMicPermission()
99
- val restored = stateStore.loadConfig() ?: return
100
- config = restored.copy(active = true, paused = false, errorMessage = null)
101
- stateStore.saveConfig(config!!)
102
- startForegroundServiceUi()
103
- serviceScope.launch {
104
- drainPendingUploads()
105
- startCaptureLoop()
106
- }
107
- }
108
-
109
- private fun pauseRecording() {
110
- val current = config ?: return
111
- config = current.copy(active = true, paused = true)
112
- stateStore.saveConfig(config!!)
113
- captureJob?.cancel()
114
- stopRecorder()
115
- updateNotification()
116
- }
117
-
118
- private fun resumeRecording() {
119
- val current = config ?: stateStore.loadConfig() ?: return
120
- config = current.copy(active = true, paused = false, errorMessage = null)
121
- stateStore.saveConfig(config!!)
122
- startForegroundServiceUi()
123
- serviceScope.launch {
124
- drainPendingUploads()
125
- startCaptureLoop()
126
- }
127
- }
128
-
129
- private fun stopRecording(finalize: Boolean) {
130
- val current = config ?: stateStore.loadConfig() ?: return
131
- serviceScope.launch {
132
- captureJob?.cancel()
133
- stopRecorder()
134
- try {
135
- drainPendingUploads()
136
- if (finalize) {
137
- uploadClient.finalizeSession(
138
- backendUrl = current.backendUrl,
139
- sessionCookie = current.sessionCookie,
140
- sessionId = current.sessionId,
141
- stopReason = "stopped",
142
- )
143
- }
144
- stateStore.clear()
145
- config = null
146
- } catch (error: Exception) {
147
- config = current.copy(
148
- active = false,
149
- paused = false,
150
- errorMessage = error.message,
151
- )
152
- stateStore.saveConfig(config!!)
153
- } finally {
154
- stopForeground(STOP_FOREGROUND_REMOVE)
155
- stopSelf()
156
- }
157
- }
158
- }
159
-
160
- private suspend fun startCaptureLoop() {
161
- if (captureJob?.isActive == true) {
162
- return
163
- }
164
- val current = config ?: return
165
- val recorderConfig = buildRecorderConfig()
166
- val sampleRate = recorderConfig.sampleRate
167
- val record = recorderConfig.audioRecord
168
- audioRecord = record
169
- attachAudioEnhancers(record.audioSessionId)
170
- record.startRecording()
171
- if (record.recordingState != AudioRecord.RECORDSTATE_RECORDING) {
172
- stopRecorder()
173
- throw IllegalStateException("Microphone capture did not start correctly.")
174
- }
175
-
176
- captureJob = serviceScope.launch {
177
- val readBuffer = ByteArray(maxOf(recorderConfig.bufferSize, 4096))
178
- val chunkBuffer = ByteArrayOutputStream()
179
- var chunkStartMs = config?.capturedAudioMs ?: 0L
180
- var chunkBytes = 0L
181
- var capturedBytes = millisToBytes(current.capturedAudioMs, sampleRate)
182
-
183
- while (isActive) {
184
- val read = record.read(readBuffer, 0, readBuffer.size)
185
- if (read <= 0) {
186
- delay(20)
187
- continue
188
- }
189
- chunkBuffer.write(readBuffer, 0, read)
190
- chunkBytes += read.toLong()
191
- capturedBytes += read.toLong()
192
- val currentConfig = config ?: break
193
- val updatedCaptured = bytesToMillis(capturedBytes, sampleRate)
194
- config = currentConfig.copy(capturedAudioMs = updatedCaptured)
195
- stateStore.saveConfig(config!!)
196
-
197
- if (bytesToMillis(chunkBytes, sampleRate) >= CHUNK_DURATION_MS) {
198
- val chunkEndMs = chunkStartMs + bytesToMillis(chunkBytes, sampleRate)
199
- flushChunk(
200
- bytes = chunkBuffer.toByteArray(),
201
- startMs = chunkStartMs,
202
- endMs = chunkEndMs,
203
- sampleRate = sampleRate,
204
- )
205
- chunkBuffer.reset()
206
- chunkStartMs = chunkEndMs
207
- chunkBytes = 0L
208
- }
209
- }
210
-
211
- if (chunkBuffer.size() > 0) {
212
- flushChunk(
213
- bytes = chunkBuffer.toByteArray(),
214
- startMs = chunkStartMs,
215
- endMs = chunkStartMs + bytesToMillis(chunkBytes, sampleRate),
216
- sampleRate = sampleRate,
217
- )
218
- }
219
- }
220
- updateNotification()
221
- }
222
-
223
- private fun flushChunk(bytes: ByteArray, startMs: Long, endMs: Long, sampleRate: Int) {
224
- if (bytes.isEmpty()) {
225
- return
226
- }
227
- val current = config ?: return
228
- val sequence = current.nextSequence
229
- val pendingDir = pendingDir(current.sessionId)
230
- pendingDir.mkdirs()
231
- val audioFile = File(pendingDir, "${sequence.toString().padStart(6, '0')}.wav")
232
- val metaFile = File(pendingDir, "${sequence.toString().padStart(6, '0')}.json")
233
-
234
- val audioTemp = File(audioFile.absolutePath + ".tmp")
235
- val metaTemp = File(metaFile.absolutePath + ".tmp")
236
-
237
- fun deleteTempFile(file: File, failureMessage: String) {
238
- if (file.exists() && !file.delete()) {
239
- android.util.Log.w(TAG, "$failureMessage: ${file.absolutePath}")
240
- }
241
- }
242
-
243
- try {
244
- audioTemp.writeBytes(wrapPcmAsWav(bytes, sampleRate))
245
- val meta = JSONObject()
246
- .put("sequence", sequence)
247
- .put("startMs", startMs)
248
- .put("endMs", endMs)
249
- .put("mimeType", "audio/wav")
250
- .put("sampleRate", sampleRate)
251
- OutputStreamWriter(metaTemp.outputStream(), Charsets.UTF_8).use { writer ->
252
- writer.write(meta.toString())
253
- }
254
-
255
- if (!audioTemp.renameTo(audioFile)) {
256
- deleteTempFile(
257
- audioTemp,
258
- "Failed to remove orphan audio temp file after audio rename failure",
259
- )
260
- throw IllegalStateException("Failed to persist recording chunk audio file.")
261
- }
262
-
263
- if (!metaTemp.renameTo(metaFile)) {
264
- deleteTempFile(
265
- metaTemp,
266
- "Failed to remove orphan metadata temp file after metadata rename failure",
267
- )
268
- if (audioFile.exists() && !audioFile.delete()) {
269
- android.util.Log.w(TAG, "Failed to roll back persisted audio file after metadata rename failure: ${audioFile.absolutePath}")
270
- }
271
- throw IllegalStateException("Failed to persist recording chunk metadata file.")
272
- }
273
- } catch (err: Exception) {
274
- deleteTempFile(audioTemp, "Failed to remove orphan audio temp file")
275
- deleteTempFile(metaTemp, "Failed to remove orphan metadata temp file")
276
- throw err
277
- }
278
-
279
- config = current.copy(nextSequence = sequence + 1)
280
- stateStore.saveConfig(config!!)
281
- serviceScope.launch {
282
- drainPendingUploads()
283
- }
284
- }
285
-
286
- private suspend fun drainPendingUploads() {
287
- val current = config ?: return
288
- uploadMutex.withLock {
289
- val pendingDir = pendingDir(current.sessionId)
290
- if (!pendingDir.exists()) {
291
- return
292
- }
293
- val metaFiles = pendingDir.listFiles { _, name -> name.endsWith(".json") }
294
- ?.sortedBy { it.nameWithoutExtension }
295
- ?: emptyList()
296
-
297
- for (metaFile in metaFiles) {
298
- val audioFile = File(pendingDir, "${metaFile.nameWithoutExtension}.wav")
299
- if (!audioFile.exists()) {
300
- val currentConfig = config
301
- if (currentConfig != null) {
302
- config = currentConfig.copy(
303
- errorMessage = "Missing audio file for pending chunk ${metaFile.nameWithoutExtension}",
304
- )
305
- stateStore.saveConfig(config!!)
306
- }
307
- if (!metaFile.delete()) {
308
- android.util.Log.w(TAG, "Failed to remove orphan metadata file: ${metaFile.absolutePath}")
309
- }
310
- continue
311
- }
312
- val metaJson = JSONObject(metaFile.readText())
313
- val meta = PendingChunkMeta(
314
- sequence = metaJson.getInt("sequence"),
315
- startMs = metaJson.getLong("startMs"),
316
- endMs = metaJson.getLong("endMs"),
317
- mimeType = metaJson.optString("mimeType", "audio/wav"),
318
- )
319
- retryUpload(audioFile, meta)
320
- audioFile.delete()
321
- metaFile.delete()
322
- }
323
- }
324
- }
325
-
326
- private suspend fun retryUpload(audioFile: File, meta: PendingChunkMeta) {
327
- val current = config ?: return
328
- var lastError: Exception? = null
329
- repeat(5) { attempt ->
330
- try {
331
- uploadClient.uploadChunk(
332
- backendUrl = current.backendUrl,
333
- sessionCookie = current.sessionCookie,
334
- sessionId = current.sessionId,
335
- meta = meta,
336
- file = audioFile,
337
- )
338
- return
339
- } catch (error: Exception) {
340
- lastError = error
341
- val latest = config ?: current
342
- config = latest.copy(errorMessage = error.message)
343
- stateStore.saveConfig(config!!)
344
- delay((attempt + 1) * 600L)
345
- }
346
- }
347
- throw lastError ?: IllegalStateException("Upload failed.")
348
- }
349
-
350
- private fun startForegroundServiceUi() {
351
- createChannel()
352
- startForeground(NOTIFICATION_ID, buildNotification())
353
- }
354
-
355
- private fun updateNotification() {
356
- val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
357
- manager.notify(NOTIFICATION_ID, buildNotification())
358
- }
359
-
360
- private fun buildNotification(): Notification {
361
- val current = config
362
- val openIntent = packageManager.getLaunchIntentForPackage(packageName)
363
- val pendingIntent = PendingIntent.getActivity(
364
- this,
365
- 0,
366
- openIntent,
367
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
368
- )
369
- val paused = current?.paused == true
370
- return NotificationCompat.Builder(this, CHANNEL_ID)
371
- .setSmallIcon(android.R.drawable.ic_btn_speak_now)
372
- .setContentTitle(if (paused) "NeoAgent recording paused" else "NeoAgent recording")
373
- .setContentText(
374
- if (paused) {
375
- "Background microphone capture is paused."
376
- } else {
377
- "Background microphone capture is running."
378
- },
379
- )
380
- .setOngoing(true)
381
- .setOnlyAlertOnce(true)
382
- .setContentIntent(pendingIntent)
383
- .build()
384
- }
385
-
386
- private fun createChannel() {
387
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
388
- return
389
- }
390
- val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
391
- val channel = NotificationChannel(
392
- CHANNEL_ID,
393
- "NeoAgent recordings",
394
- NotificationManager.IMPORTANCE_LOW,
395
- )
396
- manager.createNotificationChannel(channel)
397
- }
398
-
399
- private fun stopRecorder() {
400
- try {
401
- audioRecord?.stop()
402
- } catch (_: Exception) {
403
- }
404
- noiseSuppressor?.release()
405
- noiseSuppressor = null
406
- automaticGainControl?.release()
407
- automaticGainControl = null
408
- audioRecord?.release()
409
- audioRecord = null
410
- }
411
-
412
- private fun pendingDir(sessionId: String): File =
413
- File(filesDir, "recording-pending/$sessionId")
414
-
415
- private fun wrapPcmAsWav(pcmBytes: ByteArray, sampleRate: Int): ByteArray {
416
- val byteRate = sampleRate * CHANNEL_COUNT * BYTES_PER_SAMPLE
417
- val totalDataLen = pcmBytes.size + 36
418
- return ByteArrayOutputStream().use { output ->
419
- output.write("RIFF".toByteArray())
420
- output.write(intToLittleEndian(totalDataLen))
421
- output.write("WAVE".toByteArray())
422
- output.write("fmt ".toByteArray())
423
- output.write(intToLittleEndian(16))
424
- output.write(shortToLittleEndian(1))
425
- output.write(shortToLittleEndian(CHANNEL_COUNT.toShort()))
426
- output.write(intToLittleEndian(sampleRate))
427
- output.write(intToLittleEndian(byteRate))
428
- output.write(shortToLittleEndian((CHANNEL_COUNT * BYTES_PER_SAMPLE).toShort()))
429
- output.write(shortToLittleEndian((BYTES_PER_SAMPLE * 8).toShort()))
430
- output.write("data".toByteArray())
431
- output.write(intToLittleEndian(pcmBytes.size))
432
- output.write(pcmBytes)
433
- output.toByteArray()
434
- }
435
- }
436
-
437
- private fun intToLittleEndian(value: Int): ByteArray = byteArrayOf(
438
- (value and 0xff).toByte(),
439
- (value shr 8 and 0xff).toByte(),
440
- (value shr 16 and 0xff).toByte(),
441
- (value shr 24 and 0xff).toByte(),
442
- )
443
-
444
- private fun shortToLittleEndian(value: Short): ByteArray = byteArrayOf(
445
- (value.toInt() and 0xff).toByte(),
446
- (value.toInt() shr 8 and 0xff).toByte(),
447
- )
448
-
449
- private fun ensureMicPermission() {
450
- val granted = ContextCompat.checkSelfPermission(
451
- this,
452
- Manifest.permission.RECORD_AUDIO,
453
- ) == PackageManager.PERMISSION_GRANTED
454
- require(granted) { "Microphone permission is required." }
455
- }
456
-
457
- private fun buildRecorderConfig(): RecorderConfig {
458
- val sampleRates = listOf(16_000, 48_000, 44_100)
459
- val audioSources = listOf(
460
- MediaRecorder.AudioSource.VOICE_RECOGNITION,
461
- MediaRecorder.AudioSource.MIC,
462
- )
463
- var lastError: String? = null
464
-
465
- for (audioSource in audioSources) {
466
- for (sampleRate in sampleRates) {
467
- val minBufferSize = AudioRecord.getMinBufferSize(
468
- sampleRate,
469
- AudioFormat.CHANNEL_IN_MONO,
470
- AudioFormat.ENCODING_PCM_16BIT,
471
- )
472
- if (minBufferSize <= 0) {
473
- lastError = "Unsupported buffer size for ${sampleRate} Hz."
474
- continue
475
- }
476
-
477
- val bufferSize = maxOf(minBufferSize * 2, 4096)
478
- val record = try {
479
- AudioRecord(
480
- audioSource,
481
- sampleRate,
482
- AudioFormat.CHANNEL_IN_MONO,
483
- AudioFormat.ENCODING_PCM_16BIT,
484
- bufferSize,
485
- )
486
- } catch (error: Exception) {
487
- lastError = error.message
488
- null
489
- }
490
-
491
- if (record?.state == AudioRecord.STATE_INITIALIZED) {
492
- return RecorderConfig(
493
- audioRecord = record,
494
- sampleRate = sampleRate,
495
- bufferSize = bufferSize,
496
- )
497
- }
498
-
499
- record?.release()
500
- lastError = "AudioRecord could not initialize for ${sampleRate} Hz."
501
- }
502
- }
503
-
504
- throw IllegalStateException(lastError ?: "Unable to initialize microphone capture.")
505
- }
506
-
507
- private fun attachAudioEnhancers(audioSessionId: Int) {
508
- if (NoiseSuppressor.isAvailable()) {
509
- noiseSuppressor = NoiseSuppressor.create(audioSessionId)?.apply {
510
- enabled = true
511
- }
512
- }
513
- if (AutomaticGainControl.isAvailable()) {
514
- automaticGainControl = AutomaticGainControl.create(audioSessionId)?.apply {
515
- enabled = true
516
- }
517
- }
518
- }
519
-
520
- private fun bytesToMillis(byteCount: Long, sampleRate: Int): Long {
521
- val bytesPerSecond = sampleRate.toLong() * CHANNEL_COUNT * BYTES_PER_SAMPLE
522
- if (bytesPerSecond <= 0L) {
523
- return 0L
524
- }
525
- return (byteCount * 1000L) / bytesPerSecond
526
- }
527
-
528
- private fun millisToBytes(durationMs: Long, sampleRate: Int): Long {
529
- return (durationMs * sampleRate.toLong() * CHANNEL_COUNT * BYTES_PER_SAMPLE) / 1000L
530
- }
531
-
532
- companion object {
533
- private const val TAG = "RecordingForeground"
534
- private const val ACTION_START = "neoagent.recordings.START"
535
- private const val ACTION_RESTORE = "neoagent.recordings.RESTORE"
536
- private const val ACTION_PAUSE = "neoagent.recordings.PAUSE"
537
- private const val ACTION_RESUME = "neoagent.recordings.RESUME"
538
- private const val ACTION_STOP = "neoagent.recordings.STOP"
539
- private const val EXTRA_BACKEND_URL = "backend_url"
540
- private const val EXTRA_SESSION_COOKIE = "session_cookie"
541
- private const val EXTRA_SESSION_ID = "session_id"
542
- private const val CHANNEL_ID = "neoagent_recordings"
543
- private const val NOTIFICATION_ID = 4021
544
- private const val CHANNEL_COUNT = 1
545
- private const val BYTES_PER_SAMPLE = 2
546
- private const val CHUNK_DURATION_MS = 4_000L
547
-
548
- fun buildStartIntent(
549
- context: Context,
550
- backendUrl: String,
551
- sessionCookie: String,
552
- sessionId: String,
553
- ): Intent = Intent(context, RecordingForegroundService::class.java).apply {
554
- action = ACTION_START
555
- putExtra(EXTRA_BACKEND_URL, backendUrl)
556
- putExtra(EXTRA_SESSION_COOKIE, sessionCookie)
557
- putExtra(EXTRA_SESSION_ID, sessionId)
558
- }
559
-
560
- fun buildRestoreIntent(context: Context): Intent =
561
- Intent(context, RecordingForegroundService::class.java).apply {
562
- action = ACTION_RESTORE
563
- }
564
-
565
- fun buildPauseIntent(context: Context): Intent =
566
- Intent(context, RecordingForegroundService::class.java).apply {
567
- action = ACTION_PAUSE
568
- }
569
-
570
- fun buildResumeIntent(context: Context): Intent =
571
- Intent(context, RecordingForegroundService::class.java).apply {
572
- action = ACTION_RESUME
573
- }
574
-
575
- fun buildStopIntent(context: Context): Intent =
576
- Intent(context, RecordingForegroundService::class.java).apply {
577
- action = ACTION_STOP
578
- }
579
- }
580
- }
581
-
582
- private data class RecorderConfig(
583
- val audioRecord: AudioRecord,
584
- val sampleRate: Int,
585
- val bufferSize: Int,
586
- )