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
@@ -0,0 +1,616 @@
1
+ "use strict";
2
+
3
+ const crypto = require("crypto");
4
+ const fs = require("fs");
5
+ const path = require("path");
6
+ const db = require("../../../db/database");
7
+ const { resolveAgentId } = require("../../agents/manager");
8
+ const {
9
+ getProviderConfig,
10
+ setProviderConfig,
11
+ deleteProviderConfig,
12
+ } = require("../provider_config_store");
13
+ const { getConnectionAccessMode } = require("../access");
14
+ const { decryptValue } = require("../secrets");
15
+ const { appendQuery, fetchJson } = require("../oauth_provider");
16
+ const { resolvePublicBaseUrl } = require("../env");
17
+
18
+ const KEY = "neoarchive";
19
+ const APP = Object.freeze({
20
+ id: "archive",
21
+ label: "Archive",
22
+ description:
23
+ "Search, manage, and upload documents in a connected NeoArchive account.",
24
+ });
25
+ const SCOPES = Object.freeze([
26
+ "documents:read",
27
+ "documents:write",
28
+ "search:read",
29
+ "labels:read",
30
+ "authors:read",
31
+ "document-types:read",
32
+ ]);
33
+ const TOOLS = Object.freeze(
34
+ [
35
+ {
36
+ name: "neoarchive_list_documents",
37
+ access: "read",
38
+ description: "List documents in NeoArchive.",
39
+ parameters: {
40
+ type: "object",
41
+ properties: {
42
+ status: { type: "string" },
43
+ archived: { type: "boolean" },
44
+ limit: { type: "number" },
45
+ offset: { type: "number" },
46
+ },
47
+ },
48
+ },
49
+ {
50
+ name: "neoarchive_search_documents",
51
+ access: "read",
52
+ description: "Search NeoArchive documents by text or semantic query.",
53
+ parameters: {
54
+ type: "object",
55
+ properties: {
56
+ query: { type: "string" },
57
+ mode: { type: "string", enum: ["fulltext", "semantic", "hybrid"] },
58
+ limit: { type: "number" },
59
+ },
60
+ required: ["query"],
61
+ },
62
+ },
63
+ {
64
+ name: "neoarchive_get_document",
65
+ access: "read",
66
+ description: "Get NeoArchive document metadata and detail.",
67
+ parameters: {
68
+ type: "object",
69
+ properties: { document_id: { type: "string" } },
70
+ required: ["document_id"],
71
+ },
72
+ },
73
+ {
74
+ name: "neoarchive_get_document_text",
75
+ access: "read",
76
+ description: "Get extracted or OCR text for a NeoArchive document.",
77
+ parameters: {
78
+ type: "object",
79
+ properties: { document_id: { type: "string" } },
80
+ required: ["document_id"],
81
+ },
82
+ },
83
+ {
84
+ name: "neoarchive_list_labels",
85
+ access: "read",
86
+ description: "List NeoArchive labels.",
87
+ parameters: { type: "object", properties: {} },
88
+ },
89
+ {
90
+ name: "neoarchive_list_authors",
91
+ access: "read",
92
+ description: "List NeoArchive authors.",
93
+ parameters: { type: "object", properties: {} },
94
+ },
95
+ {
96
+ name: "neoarchive_list_document_types",
97
+ access: "read",
98
+ description: "List NeoArchive document types.",
99
+ parameters: { type: "object", properties: {} },
100
+ },
101
+ {
102
+ name: "neoarchive_upload_document",
103
+ access: "write",
104
+ description: "Upload a readable local file to NeoArchive.",
105
+ parameters: {
106
+ type: "object",
107
+ properties: {
108
+ file_path: {
109
+ type: "string",
110
+ description: "Absolute path to the local file.",
111
+ },
112
+ },
113
+ required: ["file_path"],
114
+ },
115
+ },
116
+ {
117
+ name: "neoarchive_update_document",
118
+ access: "write",
119
+ description: "Update NeoArchive document metadata.",
120
+ parameters: {
121
+ type: "object",
122
+ properties: {
123
+ document_id: { type: "string" },
124
+ metadata: { type: "object" },
125
+ },
126
+ required: ["document_id", "metadata"],
127
+ },
128
+ },
129
+ {
130
+ name: "neoarchive_archive_document",
131
+ access: "write",
132
+ description: "Archive a NeoArchive document.",
133
+ parameters: {
134
+ type: "object",
135
+ properties: { document_id: { type: "string" } },
136
+ required: ["document_id"],
137
+ },
138
+ },
139
+ {
140
+ name: "neoarchive_restore_document",
141
+ access: "write",
142
+ description: "Restore an archived NeoArchive document.",
143
+ parameters: {
144
+ type: "object",
145
+ properties: { document_id: { type: "string" } },
146
+ required: ["document_id"],
147
+ },
148
+ },
149
+ {
150
+ name: "neoarchive_reprocess_document",
151
+ access: "write",
152
+ description: "Queue a NeoArchive document for reprocessing.",
153
+ parameters: {
154
+ type: "object",
155
+ properties: {
156
+ document_id: { type: "string" },
157
+ force_ocr: { type: "boolean" },
158
+ },
159
+ required: ["document_id"],
160
+ },
161
+ },
162
+ ].map((tool) => ({ ...tool, appId: APP.id })),
163
+ );
164
+
165
+ function text(value) {
166
+ return String(value || "").trim();
167
+ }
168
+ function isPrivateHost(host) {
169
+ const value = String(host || "").toLowerCase();
170
+ return (
171
+ value === "localhost" ||
172
+ value === "::1" ||
173
+ value.startsWith("127.") ||
174
+ value.startsWith("10.") ||
175
+ value.startsWith("192.168.") ||
176
+ /^172\.(1[6-9]|2\d|3[01])\./.test(value)
177
+ );
178
+ }
179
+ function normalizeBaseUrl(value) {
180
+ const raw = text(value);
181
+ if (!raw) throw new Error("NeoArchive backend URL is required.");
182
+ const candidate = raw.includes("://")
183
+ ? raw
184
+ : `${isPrivateHost(raw.split("/")[0]) ? "http" : "https"}://${raw}`;
185
+ let url;
186
+ try {
187
+ url = new URL(candidate);
188
+ } catch {
189
+ throw new Error(
190
+ "NeoArchive backend URL must be a valid HTTP or HTTPS URL.",
191
+ );
192
+ }
193
+ if (!["http:", "https:"].includes(url.protocol) || url.hash)
194
+ throw new Error(
195
+ "NeoArchive backend URL must be an HTTP or HTTPS URL without a fragment.",
196
+ );
197
+ url.pathname = url.pathname.replace(/\/+$/, "");
198
+ return url.toString().replace(/\/+$/, "");
199
+ }
200
+ function config(input, existing = {}) {
201
+ return { baseUrl: text(input?.baseUrl) || text(existing.baseUrl) };
202
+ }
203
+ function callbackUrl() {
204
+ return `${resolvePublicBaseUrl()}/api/integrations/oauth/callback`;
205
+ }
206
+ function credentials(connection) {
207
+ try {
208
+ return JSON.parse(
209
+ decryptValue(connection.credentials_json || "{}") || "{}",
210
+ );
211
+ } catch {
212
+ return {};
213
+ }
214
+ }
215
+ function account(connection, env) {
216
+ return !env.configured
217
+ ? {
218
+ id: connection?.id || null,
219
+ status: "env_not_configured",
220
+ connected: false,
221
+ accountEmail: connection?.account_email || null,
222
+ }
223
+ : !connection
224
+ ? {
225
+ id: null,
226
+ status: "not_connected",
227
+ connected: false,
228
+ accountEmail: null,
229
+ }
230
+ : {
231
+ id: connection.id,
232
+ status: connection.status,
233
+ connected: connection.status === "connected",
234
+ accountEmail: connection.account_email || null,
235
+ lastConnectedAt: connection.last_connected_at || null,
236
+ accessMode: getConnectionAccessMode(connection),
237
+ };
238
+ }
239
+ function snapshot(provider, rows, context) {
240
+ const env = provider.getEnvStatus(context);
241
+ const accounts = rows.map((row) => account(row, env));
242
+ const connected = accounts.filter((item) => item.connected);
243
+ const connection = {
244
+ status: !env.configured
245
+ ? "env_not_configured"
246
+ : connected.length
247
+ ? "connected"
248
+ : "not_connected",
249
+ connected: connected.length > 0,
250
+ accountCount: connected.length,
251
+ accountEmail: connected.length === 1 ? connected[0].accountEmail : null,
252
+ lastConnectedAt:
253
+ connected
254
+ .map((item) => item.lastConnectedAt)
255
+ .filter(Boolean)
256
+ .sort()
257
+ .reverse()[0] || null,
258
+ };
259
+ return {
260
+ id: KEY,
261
+ label: "NeoArchive",
262
+ description: provider.description,
263
+ icon: "neoarchive",
264
+ apps: [
265
+ {
266
+ ...APP,
267
+ accounts,
268
+ connection,
269
+ availableToolCount: connection.connected ? TOOLS.length : 0,
270
+ },
271
+ ],
272
+ env,
273
+ connection,
274
+ availableToolCount: connection.connected ? TOOLS.length : 0,
275
+ connectPrompt: provider.connectPrompt,
276
+ supportsMultipleAccounts: true,
277
+ connectionMethod: "oauth",
278
+ };
279
+ }
280
+ async function bootstrap(baseUrl) {
281
+ return fetchJson(
282
+ `${baseUrl}/api/oauth/companion/neoagent/bootstrap`,
283
+ {
284
+ method: "POST",
285
+ json: { redirectUri: callbackUrl(), appName: "NeoAgent" },
286
+ },
287
+ { serviceName: "NeoArchive companion bootstrap" },
288
+ );
289
+ }
290
+ async function token(baseUrl, form) {
291
+ return fetchJson(
292
+ `${baseUrl}/oauth/token`,
293
+ { method: "POST", form },
294
+ { serviceName: "NeoArchive OAuth token" },
295
+ );
296
+ }
297
+ async function access(connection) {
298
+ const saved = credentials(connection);
299
+ const baseUrl = normalizeBaseUrl(saved.baseUrl);
300
+ if (saved.access_token && Number(saved.expires_at_ms) > Date.now() + 60000)
301
+ return { baseUrl, accessToken: saved.access_token, credentials: saved };
302
+ if (!saved.refresh_token || !saved.client_id)
303
+ throw new Error(
304
+ "NeoArchive refresh token is missing. Reconnect the NeoArchive account.",
305
+ );
306
+ const refreshed = await token(baseUrl, {
307
+ grant_type: "refresh_token",
308
+ client_id: saved.client_id,
309
+ refresh_token: saved.refresh_token,
310
+ });
311
+ const next = {
312
+ ...saved,
313
+ access_token: text(refreshed.access_token),
314
+ refresh_token: text(refreshed.refresh_token) || saved.refresh_token,
315
+ expires_at_ms:
316
+ Date.now() + Math.max(1, Number(refreshed.expires_in) || 3600) * 1000,
317
+ scope: text(refreshed.scope) || saved.scope,
318
+ };
319
+ return { baseUrl, accessToken: next.access_token, credentials: next };
320
+ }
321
+ async function request(connection, apiPath, options = {}) {
322
+ const auth = await access(connection);
323
+ const response = await fetchJson(
324
+ `${auth.baseUrl}${apiPath}`,
325
+ {
326
+ method: options.method || "GET",
327
+ headers: { Authorization: `Bearer ${auth.accessToken}` },
328
+ ...(options.json === undefined ? {} : { json: options.json }),
329
+ },
330
+ { serviceName: "NeoArchive API" },
331
+ );
332
+ return { response, credentials: auth.credentials };
333
+ }
334
+ function required(value, name) {
335
+ const normalized = text(value);
336
+ if (!normalized) throw new Error(`${name} is required.`);
337
+ return normalized;
338
+ }
339
+ async function upload(connection, filePath) {
340
+ const raw = text(filePath);
341
+ if (!raw || raw.split(/[\\/]+/).includes(".."))
342
+ throw new Error(
343
+ "file_path must be an absolute readable file path without parent traversal.",
344
+ );
345
+ const resolved = path.resolve(raw);
346
+ const stats = await fs.promises.stat(resolved);
347
+ if (!stats.isFile())
348
+ throw new Error("file_path must point to a readable file.");
349
+ await fs.promises.access(resolved, fs.constants.R_OK);
350
+ const auth = await access(connection);
351
+ const form = new FormData();
352
+ form.append(
353
+ "files",
354
+ new Blob([await fs.promises.readFile(resolved)]),
355
+ path.basename(resolved),
356
+ );
357
+ const response = await fetch(`${auth.baseUrl}/api/v1/documents`, {
358
+ method: "POST",
359
+ headers: { Authorization: `Bearer ${auth.accessToken}` },
360
+ body: form,
361
+ });
362
+ const result = await response.json().catch(() => null);
363
+ if (!response.ok)
364
+ throw new Error(
365
+ `NeoArchive upload request failed: ${result?.error || response.statusText}`,
366
+ );
367
+ return { result, credentials: auth.credentials };
368
+ }
369
+ async function execute(toolName, args, connection) {
370
+ let pathName;
371
+ let method = "GET";
372
+ let json;
373
+ const query = {};
374
+ switch (toolName) {
375
+ case "neoarchive_list_documents":
376
+ ["status", "archived", "limit", "offset"].forEach((key) => {
377
+ if (args[key] !== undefined) query[key] = args[key];
378
+ });
379
+ pathName = appendQuery("/api/v1/documents", query);
380
+ break;
381
+ case "neoarchive_search_documents":
382
+ pathName = appendQuery("/api/v1/search", {
383
+ q: required(args.query, "query"),
384
+ mode: text(args.mode) || undefined,
385
+ limit: args.limit,
386
+ });
387
+ break;
388
+ case "neoarchive_get_document":
389
+ pathName = `/api/v1/documents/${encodeURIComponent(required(args.document_id, "document_id"))}`;
390
+ break;
391
+ case "neoarchive_get_document_text":
392
+ pathName = `/api/v1/documents/${encodeURIComponent(required(args.document_id, "document_id"))}/text`;
393
+ break;
394
+ case "neoarchive_list_labels":
395
+ pathName = "/api/v1/labels";
396
+ break;
397
+ case "neoarchive_list_authors":
398
+ pathName = "/api/v1/authors";
399
+ break;
400
+ case "neoarchive_list_document_types":
401
+ pathName = "/api/v1/document-types";
402
+ break;
403
+ case "neoarchive_upload_document":
404
+ return upload(connection, args.file_path);
405
+ case "neoarchive_update_document":
406
+ pathName = `/api/v1/documents/${encodeURIComponent(required(args.document_id, "document_id"))}`;
407
+ method = "PATCH";
408
+ json =
409
+ args.metadata && typeof args.metadata === "object" ? args.metadata : {};
410
+ break;
411
+ case "neoarchive_archive_document":
412
+ pathName = `/api/v1/documents/${encodeURIComponent(required(args.document_id, "document_id"))}/archive`;
413
+ method = "POST";
414
+ json = {};
415
+ break;
416
+ case "neoarchive_restore_document":
417
+ pathName = `/api/v1/documents/${encodeURIComponent(required(args.document_id, "document_id"))}/restore`;
418
+ method = "POST";
419
+ json = {};
420
+ break;
421
+ case "neoarchive_reprocess_document":
422
+ pathName = `/api/v1/documents/${encodeURIComponent(required(args.document_id, "document_id"))}/reprocess`;
423
+ method = "POST";
424
+ json = { forceOcr: args.force_ocr === true };
425
+ break;
426
+ default:
427
+ throw new Error(`Unsupported NeoArchive tool: ${toolName}`);
428
+ }
429
+ const result = await request(connection, pathName, { method, json });
430
+ return { result: result.response, credentials: result.credentials };
431
+ }
432
+
433
+ function createNeoArchiveProvider() {
434
+ return {
435
+ key: KEY,
436
+ label: "NeoArchive",
437
+ description:
438
+ "Connect a self-hosted NeoArchive server to search, organize, and upload documents.",
439
+ icon: "neoarchive",
440
+ apps: [APP],
441
+ connectPrompt:
442
+ "Add the NeoArchive backend URL once, then authorize NeoAgent with OAuth.",
443
+ supportsMultipleAccounts: true,
444
+ connectionMethod: "user_config",
445
+ requiresRefreshToken: true,
446
+ getApp(appId) {
447
+ return text(appId) === APP.id ? APP : null;
448
+ },
449
+ getToolAppId(name) {
450
+ return TOOLS.some((tool) => tool.name === text(name)) ? APP.id : null;
451
+ },
452
+ getEnvStatus(context = {}) {
453
+ const userId = Number(context.userId);
454
+ const stored =
455
+ Number.isInteger(userId) && userId > 0
456
+ ? config(getProviderConfig(userId, KEY, context.agentId))
457
+ : { baseUrl: "" };
458
+ const configured = Boolean(stored.baseUrl);
459
+ return {
460
+ configured,
461
+ missing: configured ? [] : ["baseUrl"],
462
+ summary: configured
463
+ ? "NeoArchive is ready for account connections."
464
+ : "Add the NeoArchive backend URL to enable account connections.",
465
+ setupMode: "user",
466
+ };
467
+ },
468
+ getToolDefinitions(options = {}) {
469
+ return new Set(options.connectedAppIds || []).has(APP.id)
470
+ ? TOOLS.slice()
471
+ : [];
472
+ },
473
+ supportsTool(name) {
474
+ return TOOLS.some((tool) => tool.name === text(name));
475
+ },
476
+ buildSnapshot(rows, context = {}) {
477
+ return snapshot(this, rows, context);
478
+ },
479
+ summarizeForModel(item) {
480
+ return !item?.env?.configured
481
+ ? "NeoArchive: setup is not complete yet."
482
+ : !item.connection?.connected
483
+ ? "NeoArchive: setup is ready, but no archive account is connected."
484
+ : "NeoArchive: connected with document search, metadata, text, upload, archive, and reprocessing tools.";
485
+ },
486
+ async beginOAuth({ state, codeVerifier, userId, agentId, appKey }) {
487
+ if (text(appKey) !== APP.id) throw new Error("Unknown NeoArchive app.");
488
+ const stored = config(
489
+ getProviderConfig(
490
+ Number(userId),
491
+ KEY,
492
+ resolveAgentId(Number(userId), agentId),
493
+ ),
494
+ );
495
+ const baseUrl = normalizeBaseUrl(stored.baseUrl);
496
+ const boot = await bootstrap(baseUrl);
497
+ const challenge = crypto
498
+ .createHash("sha256")
499
+ .update(String(codeVerifier))
500
+ .digest("base64url");
501
+ return {
502
+ url: appendQuery(
503
+ text(boot.authorizationEndpoint) || `${baseUrl}/oauth/authorize`,
504
+ {
505
+ response_type: "code",
506
+ client_id: boot.clientId,
507
+ redirect_uri: text(boot.redirectUri) || callbackUrl(),
508
+ state,
509
+ code_challenge: challenge,
510
+ code_challenge_method: "S256",
511
+ scope: Array.isArray(boot.scopes)
512
+ ? boot.scopes.join(" ")
513
+ : SCOPES.join(" "),
514
+ },
515
+ ),
516
+ };
517
+ },
518
+ async finishOAuth({ userId, agentId, code, codeVerifier }) {
519
+ const stored = config(
520
+ getProviderConfig(
521
+ Number(userId),
522
+ KEY,
523
+ resolveAgentId(Number(userId), agentId),
524
+ ),
525
+ );
526
+ const baseUrl = normalizeBaseUrl(stored.baseUrl);
527
+ const boot = await bootstrap(baseUrl);
528
+ const issued = await token(baseUrl, {
529
+ grant_type: "authorization_code",
530
+ client_id: boot.clientId,
531
+ code: text(code),
532
+ redirect_uri: text(boot.redirectUri) || callbackUrl(),
533
+ code_verifier: text(codeVerifier),
534
+ });
535
+ const accessToken = text(issued.access_token);
536
+ const refreshToken = text(issued.refresh_token);
537
+ if (!accessToken || !refreshToken)
538
+ throw new Error("NeoArchive did not return durable OAuth credentials.");
539
+ const info = await fetchJson(
540
+ `${baseUrl}/oauth/userinfo`,
541
+ { headers: { Authorization: `Bearer ${accessToken}` } },
542
+ { serviceName: "NeoArchive userinfo" },
543
+ );
544
+ const host = new URL(baseUrl).host;
545
+ const accountEmail =
546
+ text(info.email) ||
547
+ text(info.preferred_username) ||
548
+ `neoarchive:${host}`;
549
+ return {
550
+ accountEmail,
551
+ scopes: text(issued.scope).split(/\s+/).filter(Boolean),
552
+ credentials: {
553
+ baseUrl,
554
+ client_id: text(boot.clientId),
555
+ access_token: accessToken,
556
+ refresh_token: refreshToken,
557
+ scope: text(issued.scope),
558
+ expires_at_ms:
559
+ Date.now() + Math.max(1, Number(issued.expires_in) || 3600) * 1000,
560
+ },
561
+ metadata: {
562
+ baseUrl,
563
+ username: text(info.preferred_username),
564
+ email: text(info.email) || null,
565
+ },
566
+ };
567
+ },
568
+ async executeTool(toolName, args, connection) {
569
+ return execute(toolName, args || {}, connection);
570
+ },
571
+ getUserConfig({ userId, agentId }) {
572
+ const scoped = resolveAgentId(Number(userId), agentId);
573
+ const stored = config(getProviderConfig(Number(userId), KEY, scoped));
574
+ const accountCount =
575
+ db
576
+ .prepare(
577
+ "SELECT COUNT(*) AS count FROM integration_connections WHERE user_id = ? AND agent_id = ? AND provider_key = ? AND status = 'connected'",
578
+ )
579
+ .get(userId, scoped, KEY)?.count || 0;
580
+ return {
581
+ baseUrl: stored.baseUrl,
582
+ configured: Boolean(stored.baseUrl),
583
+ accountCount,
584
+ hasConnectedAccount: accountCount > 0,
585
+ };
586
+ },
587
+ async saveUserConfig({ userId, agentId, config: input }) {
588
+ const scoped = resolveAgentId(Number(userId), agentId);
589
+ const existing = config(getProviderConfig(Number(userId), KEY, scoped));
590
+ const parsed = config(input, existing);
591
+ const baseUrl = normalizeBaseUrl(parsed.baseUrl);
592
+ await fetchJson(
593
+ `${baseUrl}/api/v1/health`,
594
+ { method: "GET" },
595
+ { serviceName: "NeoArchive health check" },
596
+ );
597
+ await bootstrap(baseUrl);
598
+ setProviderConfig(Number(userId), KEY, { baseUrl }, scoped);
599
+ if (existing.baseUrl && existing.baseUrl !== baseUrl)
600
+ db.prepare(
601
+ "DELETE FROM integration_connections WHERE user_id = ? AND agent_id = ? AND provider_key = ?",
602
+ ).run(userId, scoped, KEY);
603
+ return this.getUserConfig({ userId, agentId: scoped });
604
+ },
605
+ clearUserConfig({ userId, agentId }) {
606
+ const scoped = resolveAgentId(Number(userId), agentId);
607
+ deleteProviderConfig(Number(userId), KEY, scoped);
608
+ db.prepare(
609
+ "DELETE FROM integration_connections WHERE user_id = ? AND agent_id = ? AND provider_key = ?",
610
+ ).run(userId, scoped, KEY);
611
+ return { cleared: true };
612
+ },
613
+ };
614
+ }
615
+
616
+ module.exports = { createNeoArchiveProvider };