neoagent 2.4.1-beta.9 → 2.4.1

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 (234) hide show
  1. package/.env.example +33 -3
  2. package/LICENSE +111 -56
  3. package/README.md +8 -3
  4. package/docs/configuration.md +8 -0
  5. package/docs/getting-started.md +9 -3
  6. package/docs/index.md +4 -0
  7. package/extensions/chrome-browser/background.mjs +36 -4
  8. package/extensions/chrome-browser/icons/icon128.png +0 -0
  9. package/extensions/chrome-browser/icons/icon16.png +0 -0
  10. package/extensions/chrome-browser/icons/icon48.png +0 -0
  11. package/extensions/chrome-browser/icons/logo.svg +39 -8
  12. package/extensions/chrome-browser/manifest.json +3 -3
  13. package/extensions/chrome-browser/popup.html +5 -1
  14. package/extensions/chrome-browser/popup.js +13 -0
  15. package/flutter_app/android/app/src/main/AndroidManifest.xml +2 -1
  16. package/flutter_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  17. package/flutter_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  18. package/flutter_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  19. package/flutter_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  20. package/flutter_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  21. package/flutter_app/assets/branding/app_icon_1024.png +0 -0
  22. package/flutter_app/assets/branding/app_icon_128.png +0 -0
  23. package/flutter_app/assets/branding/app_icon_192.png +0 -0
  24. package/flutter_app/assets/branding/app_icon_256.png +0 -0
  25. package/flutter_app/assets/branding/app_icon_32.png +0 -0
  26. package/flutter_app/assets/branding/app_icon_512.png +0 -0
  27. package/flutter_app/assets/branding/app_icon_64.png +0 -0
  28. package/flutter_app/assets/branding/app_icon_light_1024.png +0 -0
  29. package/flutter_app/assets/branding/app_icon_light_128.png +0 -0
  30. package/flutter_app/assets/branding/app_icon_light_192.png +0 -0
  31. package/flutter_app/assets/branding/app_icon_light_256.png +0 -0
  32. package/flutter_app/assets/branding/app_icon_light_32.png +0 -0
  33. package/flutter_app/assets/branding/app_icon_light_512.png +0 -0
  34. package/flutter_app/assets/branding/app_icon_light_64.png +0 -0
  35. package/flutter_app/assets/branding/onboarding_intro.mp4 +0 -0
  36. package/flutter_app/assets/branding/tray_icon_light_template.png +0 -0
  37. package/flutter_app/assets/branding/tray_icon_template.png +0 -0
  38. package/flutter_app/lib/features/location/location_service.dart +3 -0
  39. package/flutter_app/lib/features/onboarding/onboarding_chrome.dart +391 -382
  40. package/flutter_app/lib/features/onboarding/onboarding_companion_step.dart +743 -0
  41. package/flutter_app/lib/features/onboarding/onboarding_messaging_step.dart +18 -16
  42. package/flutter_app/lib/features/onboarding/onboarding_model_step.dart +19 -18
  43. package/flutter_app/lib/features/onboarding/onboarding_shell.dart +8 -1
  44. package/flutter_app/lib/features/onboarding/onboarding_video_step.dart +16 -13
  45. package/flutter_app/lib/features/onboarding/onboarding_welcome_step.dart +17 -13
  46. package/flutter_app/lib/main.dart +2 -0
  47. package/flutter_app/lib/main_account_settings.dart +10 -34
  48. package/flutter_app/lib/main_admin.dart +14 -1
  49. package/flutter_app/lib/main_app_shell.dart +377 -340
  50. package/flutter_app/lib/main_chat.dart +707 -227
  51. package/flutter_app/lib/main_controller.dart +197 -42
  52. package/flutter_app/lib/main_devices.dart +436 -4
  53. package/flutter_app/lib/main_integrations.dart +255 -6
  54. package/flutter_app/lib/main_launcher.dart +1 -1
  55. package/flutter_app/lib/main_model_picker.dart +685 -0
  56. package/flutter_app/lib/main_models.dart +212 -0
  57. package/flutter_app/lib/main_navigation.dart +1 -9
  58. package/flutter_app/lib/main_operations.dart +1417 -614
  59. package/flutter_app/lib/main_settings.dart +673 -871
  60. package/flutter_app/lib/main_shared.dart +971 -443
  61. package/flutter_app/lib/main_spacing.dart +4 -4
  62. package/flutter_app/lib/main_theme.dart +22 -14
  63. package/flutter_app/lib/main_unified.dart +5 -72
  64. package/flutter_app/lib/src/android_apk_drop_zone.dart +24 -0
  65. package/flutter_app/lib/src/android_apk_drop_zone_stub.dart +13 -0
  66. package/flutter_app/lib/src/android_apk_drop_zone_web.dart +219 -0
  67. package/flutter_app/lib/src/backend_client.dart +46 -0
  68. package/flutter_app/lib/src/desktop_companion_actions.dart +30 -7
  69. package/flutter_app/lib/src/desktop_companion_io.dart +71 -1
  70. package/flutter_app/lib/src/security/password_strength.dart +84 -0
  71. package/flutter_app/lib/src/stream_renderer.dart +42 -3
  72. package/flutter_app/lib/src/theme/palette.dart +76 -34
  73. package/flutter_app/linux/runner/resources/app_icon.png +0 -0
  74. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png +0 -0
  75. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png +0 -0
  76. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png +0 -0
  77. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png +0 -0
  78. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png +0 -0
  79. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png +0 -0
  80. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png +0 -0
  81. package/flutter_app/pubspec.lock +2 -2
  82. package/flutter_app/pubspec.yaml +7 -1
  83. package/flutter_app/tool/branding_source/neoagent-icon-1024.png +0 -0
  84. package/flutter_app/tool/branding_source/neoagent-icon-128.png +0 -0
  85. package/flutter_app/tool/branding_source/neoagent-icon-16.png +0 -0
  86. package/flutter_app/tool/branding_source/neoagent-icon-180.png +0 -0
  87. package/flutter_app/tool/branding_source/neoagent-icon-192.png +0 -0
  88. package/flutter_app/tool/branding_source/neoagent-icon-256.png +0 -0
  89. package/flutter_app/tool/branding_source/neoagent-icon-32.png +0 -0
  90. package/flutter_app/tool/branding_source/neoagent-icon-48.png +0 -0
  91. package/flutter_app/tool/branding_source/neoagent-icon-512.png +0 -0
  92. package/flutter_app/tool/branding_source/neoagent-icon-64.png +0 -0
  93. package/flutter_app/tool/branding_source/neoagent-icon.svg +43 -0
  94. package/flutter_app/tool/generate_desktop_branding.py +154 -152
  95. package/flutter_app/web/favicon.png +0 -0
  96. package/flutter_app/web/favicon.svg +40 -9
  97. package/flutter_app/web/favicon_light.svg +43 -0
  98. package/flutter_app/web/icons/Icon-192-light.png +0 -0
  99. package/flutter_app/web/icons/Icon-192.png +0 -0
  100. package/flutter_app/web/icons/Icon-512-light.png +0 -0
  101. package/flutter_app/web/icons/Icon-512.png +0 -0
  102. package/flutter_app/web/icons/Icon-maskable-192-light.png +0 -0
  103. package/flutter_app/web/icons/Icon-maskable-192.png +0 -0
  104. package/flutter_app/web/icons/Icon-maskable-512-light.png +0 -0
  105. package/flutter_app/web/icons/Icon-maskable-512.png +0 -0
  106. package/flutter_app/windows/runner/main.cpp +7 -1
  107. package/flutter_app/windows/runner/resources/app_icon.ico +0 -0
  108. package/lib/manager.js +445 -83
  109. package/package.json +17 -3
  110. package/runtime/paths.js +3 -1
  111. package/server/admin/access.js +198 -0
  112. package/server/admin/admin.css +268 -0
  113. package/server/admin/admin.js +348 -0
  114. package/server/admin/analytics.js +128 -0
  115. package/server/admin/index.html +1015 -0
  116. package/server/admin/login.html +290 -0
  117. package/server/admin/logo.svg +43 -0
  118. package/server/admin/sql.js +134 -0
  119. package/server/admin/users.js +147 -0
  120. package/server/config/origins.js +3 -1
  121. package/server/db/database.js +92 -0
  122. package/server/db/ftsQuery.js +27 -0
  123. package/server/http/routes.js +1 -0
  124. package/server/http/static.js +23 -6
  125. package/server/index.js +1 -1
  126. package/server/middleware/adminAuth.js +48 -0
  127. package/server/middleware/auth.js +1 -40
  128. package/server/public/.last_build_id +1 -1
  129. package/server/public/app_icon.png +0 -0
  130. package/server/public/assets/AssetManifest.bin +1 -1
  131. package/server/public/assets/AssetManifest.bin.json +1 -1
  132. package/server/public/assets/assets/branding/app_icon_1024.png +0 -0
  133. package/server/public/assets/assets/branding/app_icon_256.png +0 -0
  134. package/server/public/assets/assets/branding/app_icon_512.png +0 -0
  135. package/server/public/assets/assets/branding/app_icon_light_1024.png +0 -0
  136. package/server/public/assets/assets/branding/app_icon_light_256.png +0 -0
  137. package/server/public/assets/assets/branding/app_icon_light_512.png +0 -0
  138. package/server/public/assets/assets/branding/onboarding_intro.mp4 +0 -0
  139. package/server/public/assets/assets/branding/tray_icon_light_template.png +0 -0
  140. package/server/public/assets/assets/branding/tray_icon_template.png +0 -0
  141. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  142. package/server/public/assets/web/icons/Icon-192.png +0 -0
  143. package/server/public/favicon.png +0 -0
  144. package/server/public/favicon.svg +40 -9
  145. package/server/public/favicon_light.svg +43 -0
  146. package/server/public/flutter_bootstrap.js +2 -2
  147. package/server/public/icons/Icon-192-light.png +0 -0
  148. package/server/public/icons/Icon-192.png +0 -0
  149. package/server/public/icons/Icon-512-light.png +0 -0
  150. package/server/public/icons/Icon-512.png +0 -0
  151. package/server/public/icons/Icon-maskable-192-light.png +0 -0
  152. package/server/public/icons/Icon-maskable-192.png +0 -0
  153. package/server/public/icons/Icon-maskable-512-light.png +0 -0
  154. package/server/public/icons/Icon-maskable-512.png +0 -0
  155. package/server/public/main.dart.js +88537 -85936
  156. package/server/routes/admin.js +632 -0
  157. package/server/routes/agent_profiles.js +3 -0
  158. package/server/routes/agents.js +1 -1
  159. package/server/routes/auth.js +3 -1
  160. package/server/routes/mcp.js +29 -13
  161. package/server/routes/memory.js +23 -1
  162. package/server/routes/messaging.js +2 -0
  163. package/server/routes/screenHistory.js +14 -8
  164. package/server/routes/settings.js +10 -2
  165. package/server/routes/stream.js +1 -1
  166. package/server/routes/triggers.js +4 -4
  167. package/server/routes/voice_assistant.js +36 -1
  168. package/server/routes/workspace.js +86 -0
  169. package/server/services/account/admin_two_factor.js +132 -0
  170. package/server/services/account/password_policy.js +6 -1
  171. package/server/services/ai/completion.js +44 -0
  172. package/server/services/ai/engine.js +321 -500
  173. package/server/services/ai/imageAnalysis.js +9 -5
  174. package/server/services/ai/logFormat.js +46 -0
  175. package/server/services/ai/loopPolicy.js +11 -0
  176. package/server/services/ai/messagingFallback.js +228 -0
  177. package/server/services/ai/models.js +194 -55
  178. package/server/services/ai/providerRetry.js +169 -0
  179. package/server/services/ai/providers/anthropic.js +15 -4
  180. package/server/services/ai/providers/google.js +38 -11
  181. package/server/services/ai/providers/grok.js +19 -68
  182. package/server/services/ai/providers/grokOauth.js +141 -0
  183. package/server/services/ai/providers/nvidia.js +154 -0
  184. package/server/services/ai/providers/ollama.js +76 -39
  185. package/server/services/ai/providers/openai.js +20 -31
  186. package/server/services/ai/providers/openaiCodex.js +6 -2
  187. package/server/services/ai/providers/openaiCompatible.js +70 -0
  188. package/server/services/ai/providers/openrouter.js +162 -0
  189. package/server/services/ai/settings.js +30 -0
  190. package/server/services/ai/systemPrompt.js +51 -29
  191. package/server/services/ai/taskAnalysis.js +60 -1
  192. package/server/services/ai/toolEvidence.js +207 -0
  193. package/server/services/ai/toolSelector.js +8 -1
  194. package/server/services/ai/tools.js +78 -13
  195. package/server/services/browser/extension/registry.js +94 -9
  196. package/server/services/desktop/registry.js +104 -1
  197. package/server/services/desktop/screenRecorder.js +208 -98
  198. package/server/services/desktop/screen_recorder_support.js +46 -0
  199. package/server/services/integrations/google/provider.js +13 -0
  200. package/server/services/integrations/home_assistant/constants.js +88 -0
  201. package/server/services/integrations/home_assistant/network.js +207 -0
  202. package/server/services/integrations/home_assistant/provider.js +249 -0
  203. package/server/services/integrations/home_assistant/snapshot.js +98 -0
  204. package/server/services/integrations/home_assistant/tools.js +101 -0
  205. package/server/services/integrations/manager.js +5 -0
  206. package/server/services/integrations/registry.js +2 -0
  207. package/server/services/integrations/trello/provider.js +8 -2
  208. package/server/services/manager.js +51 -53
  209. package/server/services/mcp/client.js +208 -268
  210. package/server/services/mcp/client_support.js +172 -0
  211. package/server/services/mcp/recovery.js +116 -0
  212. package/server/services/mcp/tool_operations.js +123 -0
  213. package/server/services/memory/ingestion.js +185 -370
  214. package/server/services/memory/ingestion_coverage.js +129 -0
  215. package/server/services/memory/ingestion_documents.js +123 -0
  216. package/server/services/memory/ingestion_support.js +171 -0
  217. package/server/services/memory/intelligence.js +181 -0
  218. package/server/services/memory/manager.js +476 -32
  219. package/server/services/messaging/automation.js +71 -134
  220. package/server/services/messaging/formatting_guides.js +26 -1
  221. package/server/services/messaging/inbound_queue.js +111 -0
  222. package/server/services/messaging/manager.js +25 -8
  223. package/server/services/messaging/typing_keepalive.js +110 -0
  224. package/server/services/streaming/android-stream.js +293 -40
  225. package/server/services/tasks/integration_runtime.js +4 -1
  226. package/server/services/tasks/runtime.js +415 -56
  227. package/server/services/tasks/task_repository.js +48 -6
  228. package/server/services/websocket.js +8 -2
  229. package/server/services/widgets/service.js +17 -1
  230. package/server/services/workspace/manager.js +116 -0
  231. package/server/utils/logger.js +44 -6
  232. package/server/utils/security.js +3 -0
  233. package/server/utils/version.js +29 -19
  234. package/server/services/memory/openhuman_uplift.test.js +0 -98
@@ -3,292 +3,136 @@
3
3
  const { v4: uuidv4 } = require('uuid');
4
4
  const db = require('../../db/database');
5
5
  const { resolveAgentId } = require('../agents/manager');
6
- const { compactTextPayload } = require('../ai/preModelCompaction');
7
-
8
- const SOURCE_TYPES = Object.freeze([
9
- 'email',
10
- 'calendar',
11
- 'chat',
12
- 'docs',
13
- 'tickets',
14
- 'repos',
15
- 'files',
16
- 'crm',
17
- 'payments',
18
- 'notes',
19
- ]);
20
-
21
- const FRESHNESS_POLICIES = Object.freeze({
22
- email: Object.freeze({ intervalMinutes: 60, staleAfterDays: 14 }),
23
- calendar: Object.freeze({ intervalMinutes: 180, staleAfterDays: 30 }),
24
- chat: Object.freeze({ intervalMinutes: 60, staleAfterDays: 14 }),
25
- docs: Object.freeze({ intervalMinutes: 360, staleAfterDays: 60 }),
26
- tickets: Object.freeze({ intervalMinutes: 120, staleAfterDays: 30 }),
27
- repos: Object.freeze({ intervalMinutes: 180, staleAfterDays: 45 }),
28
- files: Object.freeze({ intervalMinutes: 360, staleAfterDays: 60 }),
29
- crm: Object.freeze({ intervalMinutes: 240, staleAfterDays: 45 }),
30
- payments: Object.freeze({ intervalMinutes: 360, staleAfterDays: 90 }),
31
- notes: Object.freeze({ intervalMinutes: 360, staleAfterDays: 90 }),
32
- });
33
-
34
- const SOURCE_MEMORY_CATEGORIES = Object.freeze({
35
- email: 'episodic',
36
- calendar: 'events',
37
- chat: 'episodic',
38
- docs: 'projects',
39
- tickets: 'tasks',
40
- repos: 'projects',
41
- files: 'episodic',
42
- crm: 'contacts',
43
- payments: 'events',
44
- notes: 'episodic',
45
- });
46
-
47
- const INTEGRATION_SOURCE_TYPES = Object.freeze({
48
- google_workspace: Object.freeze({
49
- gmail: Object.freeze(['email']),
50
- calendar: Object.freeze(['calendar']),
51
- drive: Object.freeze(['files']),
52
- docs: Object.freeze(['docs']),
53
- sheets: Object.freeze(['docs', 'files']),
54
- }),
55
- microsoft_365: Object.freeze({
56
- outlook: Object.freeze(['email']),
57
- calendar: Object.freeze(['calendar']),
58
- onedrive: Object.freeze(['files']),
59
- teams: Object.freeze(['chat']),
60
- }),
61
- github: Object.freeze({
62
- repos: Object.freeze(['repos', 'tickets']),
63
- }),
64
- slack: Object.freeze({
65
- slack: Object.freeze(['chat']),
66
- }),
67
- whatsapp: Object.freeze({
68
- personal: Object.freeze(['chat']),
69
- }),
70
- notion: Object.freeze({
71
- notion: Object.freeze(['notes', 'docs']),
72
- }),
73
- trello: Object.freeze({
74
- trello: Object.freeze(['tickets']),
75
- }),
76
- });
77
-
78
- function safeTrim(value, maxLength = 240) {
79
- return String(value || '').trim().slice(0, maxLength);
80
- }
81
-
82
- function parseJsonObject(value, fallback = {}) {
83
- if (!value) return { ...fallback };
84
- if (typeof value === 'object' && !Array.isArray(value)) return { ...value };
85
- try {
86
- const parsed = JSON.parse(String(value));
87
- return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
88
- ? parsed
89
- : { ...fallback };
90
- } catch {
91
- return { ...fallback };
92
- }
93
- }
94
-
95
- function normalizeSourceType(value) {
96
- const normalized = String(value || '').trim().toLowerCase();
97
- return SOURCE_TYPES.includes(normalized) ? normalized : 'notes';
98
- }
99
-
100
- function getFreshnessPolicy(sourceType) {
101
- const normalized = normalizeSourceType(sourceType);
102
- return { ...FRESHNESS_POLICIES[normalized] };
103
- }
104
-
105
- function nextSyncFromPolicy(policy, now = Date.now()) {
106
- const intervalMinutes = Math.max(15, Number(policy?.intervalMinutes) || 360);
107
- return new Date(now + intervalMinutes * 60 * 1000).toISOString();
108
- }
109
-
110
- function normalizeDocument(raw = {}, defaults = {}) {
111
- const sourceType = normalizeSourceType(raw.sourceType || defaults.sourceType);
112
- const externalObjectId = safeTrim(raw.externalObjectId || raw.id || raw.sourceId, 180);
113
- const contentSource = raw.content ?? raw.text ?? raw.body ?? '';
114
- const content = safeTrim(contentSource, 12000);
115
- if (!externalObjectId || !content) {
116
- throw new Error('Normalized memory documents require externalObjectId and content.');
117
- }
118
- const compacted = compactTextPayload(content, { maxChars: 2400, maxLines: 50 });
119
- return {
120
- sourceType,
121
- normalizedType: normalizeSourceType(raw.normalizedType || raw.memoryType || sourceType),
122
- providerKey: safeTrim(raw.providerKey || defaults.providerKey, 80),
123
- connectionId: Number.isInteger(Number(raw.connectionId ?? defaults.connectionId))
124
- ? Number(raw.connectionId ?? defaults.connectionId)
125
- : 0,
126
- externalObjectId,
127
- sourceAccount: safeTrim(raw.sourceAccount || defaults.sourceAccount, 180),
128
- title: safeTrim(raw.title || raw.subject || raw.name || externalObjectId, 220),
129
- content,
130
- summary: safeTrim(raw.summary || compacted.text, 1200),
131
- salience: Math.max(1, Math.min(10, Number(raw.salience) || 5)),
132
- sourceTimestamp: safeTrim(raw.sourceTimestamp || raw.updatedAt || raw.createdAt, 80) || null,
133
- metadata: {
134
- ...parseJsonObject(raw.metadata, {}),
135
- compaction: compacted.metrics,
136
- },
137
- payload: parseJsonObject(raw.payload, {}),
138
- };
139
- }
140
-
141
- function sourceTypesForConnection(providerKey, appKey) {
142
- const providerMap = INTEGRATION_SOURCE_TYPES[String(providerKey || '').trim()] || {};
143
- return Array.from(providerMap[String(appKey || '').trim()] || []);
144
- }
6
+ const { getErrorMessage } = require('../bootstrap_helpers');
7
+ const { ingestDocuments } = require('./ingestion_documents');
8
+ const {
9
+ decorateProviderSnapshot,
10
+ listConnectionStatuses,
11
+ } = require('./ingestion_coverage');
12
+ const {
13
+ FRESHNESS_POLICIES,
14
+ SOURCE_TYPES,
15
+ getFreshnessPolicy,
16
+ nextSyncFromPolicy,
17
+ normalizeSourceType,
18
+ sourceTypesForConnection,
19
+ } = require('./ingestion_support');
145
20
 
146
- function buildCoverageForConnection(connection, latestJob = null) {
147
- const dataDomains = sourceTypesForConnection(connection.provider_key, connection.app_key);
148
- const supported = dataDomains.length > 0;
149
- return {
150
- supported,
151
- contributesToMemory: supported,
152
- contributesToTaskExecution: String(connection.status || '') === 'connected',
153
- status: latestJob?.status || (supported ? 'ready' : 'not_supported'),
154
- dataDomains,
155
- lastRefreshAt: latestJob?.completedAt || latestJob?.updatedAt || null,
156
- nextRefreshAt: latestJob?.nextSyncAt || null,
157
- documentCount: Number(latestJob?.documentCount || 0),
158
- error: latestJob?.error || null,
159
- };
160
- }
21
+ const DEFAULT_REFRESH_INTERVAL_MS = 10 * 60 * 1000;
161
22
 
162
23
  class MemoryIngestionService {
163
- constructor({ memoryManager, integrationManager, intervalMs = 10 * 60 * 1000 } = {}) {
24
+ constructor({
25
+ memoryManager,
26
+ integrationManager,
27
+ intervalMs = DEFAULT_REFRESH_INTERVAL_MS,
28
+ database = db,
29
+ setIntervalFn = setInterval,
30
+ clearIntervalFn = clearInterval,
31
+ } = {}) {
164
32
  this.memoryManager = memoryManager;
165
33
  this.integrationManager = integrationManager;
166
- this.intervalMs = intervalMs;
34
+ this.intervalMs = Number(intervalMs);
35
+ if (!Number.isSafeInteger(this.intervalMs) || this.intervalMs < 1000) {
36
+ throw new Error('Memory ingestion intervalMs must be an integer greater than or equal to 1000.');
37
+ }
38
+ this.db = database;
39
+ this.setInterval = setIntervalFn;
40
+ this.clearInterval = clearIntervalFn;
167
41
  this.timer = null;
168
- this.running = false;
42
+ this.activeBatches = new Map();
43
+ this.activeConnections = new Map();
44
+ this.stopping = false;
45
+ this.stopPromise = null;
46
+ this.state = 'idle';
47
+ this.lastRunAt = null;
48
+ this.lastCompletedAt = null;
49
+ this.lastError = null;
50
+ }
51
+
52
+ getStatus() {
53
+ return {
54
+ state: this.state,
55
+ intervalMs: this.intervalMs,
56
+ activeBatchCount: this.activeBatches.size,
57
+ activeConnectionCount: this.activeConnections.size,
58
+ lastRunAt: this.lastRunAt,
59
+ lastCompletedAt: this.lastCompletedAt,
60
+ lastError: this.lastError,
61
+ };
169
62
  }
170
63
 
171
64
  start() {
172
- if (this.timer) return;
173
- this.timer = setInterval(() => {
174
- this.refreshDueConnections().catch((err) => {
175
- console.warn('[MemoryIngestion] Background refresh failed:', err.message);
176
- });
65
+ if (this.timer) return this.getStatus();
66
+ if (this.stopPromise) {
67
+ throw new Error('Memory ingestion cannot start while shutdown is in progress.');
68
+ }
69
+
70
+ this.stopping = false;
71
+ this.state = 'running';
72
+ this.lastError = null;
73
+ this.timer = this.setInterval(() => {
74
+ void this._runBackgroundRefresh();
177
75
  }, this.intervalMs);
178
76
  this.timer.unref?.();
77
+ void this._runBackgroundRefresh();
78
+ return this.getStatus();
179
79
  }
180
80
 
181
- stop() {
182
- if (this.timer) clearInterval(this.timer);
183
- this.timer = null;
81
+ async _runBackgroundRefresh() {
82
+ try {
83
+ await this.refreshDueConnections();
84
+ } catch (err) {
85
+ this.lastError = getErrorMessage(err);
86
+ console.warn('[MemoryIngestion] Background refresh failed:', this.lastError);
87
+ }
184
88
  }
185
89
 
186
- getFreshnessPolicy(sourceType) {
187
- return getFreshnessPolicy(sourceType);
90
+ async stop() {
91
+ if (this.stopPromise) return this.stopPromise;
92
+ this.stopping = true;
93
+ this.state = 'stopping';
94
+ if (this.timer) this.clearInterval(this.timer);
95
+ this.timer = null;
96
+
97
+ this.stopPromise = (async () => {
98
+ while (this.activeBatches.size > 0 || this.activeConnections.size > 0) {
99
+ await Promise.allSettled([
100
+ ...this.activeBatches.values(),
101
+ ...this.activeConnections.values(),
102
+ ]);
103
+ }
104
+ this.stopping = false;
105
+ this.state = 'stopped';
106
+ return this.getStatus();
107
+ })().finally(() => {
108
+ this.stopPromise = null;
109
+ });
110
+ return this.stopPromise;
188
111
  }
189
112
 
190
113
  async ingestDocuments(userId, documents = [], options = {}) {
191
- const agentId = resolveAgentId(userId, options.agentId || options.agent_id || null);
192
- const normalizedDocs = (Array.isArray(documents) ? documents : [])
193
- .map((document) => normalizeDocument(document, {
194
- sourceType: options.sourceType,
195
- providerKey: options.providerKey,
196
- connectionId: options.connectionId,
197
- sourceAccount: options.sourceAccount,
198
- }));
199
- const sourceType = normalizeSourceType(options.sourceType || normalizedDocs[0]?.sourceType);
200
- const policy = getFreshnessPolicy(sourceType);
201
- const jobId = uuidv4();
114
+ return ingestDocuments(this, userId, documents, options);
115
+ }
202
116
 
203
- this.memoryManager.recordIngestionJob(userId, {
204
- id: jobId,
205
- sourceType,
206
- providerKey: safeTrim(options.providerKey, 80),
207
- connectionId: Number.isInteger(Number(options.connectionId)) ? Number(options.connectionId) : 0,
208
- status: 'running',
209
- freshnessPolicy: policy,
210
- metadata: parseJsonObject(options.metadata, {}),
211
- documentCount: 0,
212
- nextSyncAt: nextSyncFromPolicy(policy),
213
- }, { agentId });
117
+ refreshDueConnections(userId = null) {
118
+ const scopeKey = userId == null ? 'all' : `user:${userId}`;
119
+ if (this.stopping) {
120
+ return Promise.resolve({ skipped: true, reason: 'service_stopping' });
121
+ }
122
+ const active = this.activeBatches.get(scopeKey);
123
+ if (active) return active;
214
124
 
215
- const documentIds = [];
216
- const memoryIds = [];
217
- try {
218
- for (const document of normalizedDocs) {
219
- const documentId = this.memoryManager.upsertIngestionDocument(userId, document, { agentId });
220
- documentIds.push(documentId);
221
- const memoryId = await this.memoryManager.saveMemory(
222
- userId,
223
- `${document.title}: ${document.summary || document.content}`,
224
- SOURCE_MEMORY_CATEGORIES[document.normalizedType] || SOURCE_MEMORY_CATEGORIES[document.sourceType] || 'episodic',
225
- document.salience,
226
- {
227
- agentId,
228
- staleAfterDays: getFreshnessPolicy(document.sourceType).staleAfterDays,
229
- sourceRef: {
230
- sourceType: 'memory_ingestion',
231
- sourceId: document.externalObjectId,
232
- sourceLabel: document.title,
233
- },
234
- scope: {
235
- scopeType: 'agent',
236
- scopeId: agentId,
237
- },
238
- metadata: {
239
- ingestionJobId: jobId,
240
- ingestionDocumentId: documentId,
241
- providerKey: document.providerKey || null,
242
- connectionId: document.connectionId || null,
243
- sourceType: document.sourceType,
244
- },
245
- },
246
- );
247
- if (memoryId) memoryIds.push(memoryId);
125
+ const promise = this._refreshDueConnections(userId).finally(() => {
126
+ if (this.activeBatches.get(scopeKey) === promise) {
127
+ this.activeBatches.delete(scopeKey);
248
128
  }
249
-
250
- this.memoryManager.recordIngestionJob(userId, {
251
- id: jobId,
252
- sourceType,
253
- providerKey: safeTrim(options.providerKey, 80),
254
- connectionId: Number.isInteger(Number(options.connectionId)) ? Number(options.connectionId) : 0,
255
- status: 'completed',
256
- freshnessPolicy: policy,
257
- summary: { documentIds, memoryIds },
258
- metadata: parseJsonObject(options.metadata, {}),
259
- documentCount: documentIds.length,
260
- completedAt: new Date().toISOString(),
261
- nextSyncAt: nextSyncFromPolicy(policy),
262
- }, { agentId });
263
-
264
- const knowledgeViews = this.memoryManager.materializeKnowledgeViews(userId, { agentId });
265
- return {
266
- jobId,
267
- status: 'completed',
268
- documentIds,
269
- memoryIds,
270
- knowledgeViews,
271
- };
272
- } catch (err) {
273
- this.memoryManager.recordIngestionJob(userId, {
274
- id: jobId,
275
- sourceType,
276
- providerKey: safeTrim(options.providerKey, 80),
277
- connectionId: Number.isInteger(Number(options.connectionId)) ? Number(options.connectionId) : 0,
278
- status: 'failed',
279
- freshnessPolicy: policy,
280
- documentCount: documentIds.length,
281
- error: err.message,
282
- completedAt: new Date().toISOString(),
283
- nextSyncAt: nextSyncFromPolicy(policy),
284
- }, { agentId });
285
- throw err;
286
- }
129
+ });
130
+ this.activeBatches.set(scopeKey, promise);
131
+ return promise;
287
132
  }
288
133
 
289
- async refreshDueConnections(userId = null) {
290
- if (this.running) return { skipped: true };
291
- this.running = true;
134
+ async _refreshDueConnections(userId = null) {
135
+ this.lastRunAt = new Date().toISOString();
292
136
  try {
293
137
  const params = [];
294
138
  let sql = `SELECT *
@@ -298,14 +142,75 @@ class MemoryIngestionService {
298
142
  sql += ' AND user_id = ?';
299
143
  params.push(userId);
300
144
  }
301
- const connections = db.prepare(sql).all(...params);
145
+ const connections = this.db.prepare(sql).all(...params);
302
146
  const results = [];
303
147
  for (const connection of connections) {
304
- results.push(await this.refreshConnection(connection));
148
+ if (this.stopping) break;
149
+ results.push(await this._refreshConnectionSafely(connection));
150
+ }
151
+ if (!results.some((result) => result?.status === 'failed')) {
152
+ this.lastError = null;
305
153
  }
154
+ this.lastCompletedAt = new Date().toISOString();
306
155
  return { refreshed: results.length, results };
307
- } finally {
308
- this.running = false;
156
+ } catch (err) {
157
+ this.lastError = getErrorMessage(err);
158
+ throw err;
159
+ }
160
+ }
161
+
162
+ _refreshConnectionSafely(connection) {
163
+ const connectionKey = `${connection.user_id}:${connection.id}`;
164
+ const active = this.activeConnections.get(connectionKey);
165
+ if (active) return active;
166
+
167
+ const promise = Promise.resolve()
168
+ .then(() => this.refreshConnection(connection))
169
+ .catch((err) => {
170
+ const error = getErrorMessage(err);
171
+ this.lastError = error;
172
+ console.warn(
173
+ `[MemoryIngestion] Connection ${connection.id} refresh failed:`,
174
+ error,
175
+ );
176
+ return {
177
+ connectionId: connection.id,
178
+ status: 'failed',
179
+ error,
180
+ };
181
+ })
182
+ .finally(() => {
183
+ if (this.activeConnections.get(connectionKey) === promise) {
184
+ this.activeConnections.delete(connectionKey);
185
+ }
186
+ });
187
+ this.activeConnections.set(connectionKey, promise);
188
+ return promise;
189
+ }
190
+
191
+ _recordCollectorFailure(connection, sourceType, policy, agentId, err) {
192
+ try {
193
+ this.memoryManager.recordIngestionJob(connection.user_id, {
194
+ id: uuidv4(),
195
+ sourceType,
196
+ providerKey: connection.provider_key,
197
+ connectionId: connection.id,
198
+ status: 'failed',
199
+ freshnessPolicy: policy,
200
+ documentCount: 0,
201
+ error: getErrorMessage(err),
202
+ completedAt: new Date().toISOString(),
203
+ nextSyncAt: nextSyncFromPolicy(policy),
204
+ metadata: {
205
+ appKey: connection.app_key,
206
+ sourceTypes: sourceTypesForConnection(connection.provider_key, connection.app_key),
207
+ },
208
+ }, { agentId });
209
+ } catch (recordError) {
210
+ console.warn(
211
+ `[MemoryIngestion] Could not persist failure for connection ${connection.id}:`,
212
+ getErrorMessage(recordError),
213
+ );
309
214
  }
310
215
  }
311
216
 
@@ -331,11 +236,17 @@ class MemoryIngestionService {
331
236
  }
332
237
 
333
238
  if (typeof provider?.collectMemoryDocuments === 'function') {
334
- const collected = await provider.collectMemoryDocuments({
335
- connection,
336
- sourceTypes,
337
- cursor: latestJob?.cursor || {},
338
- });
239
+ let collected;
240
+ try {
241
+ collected = await provider.collectMemoryDocuments({
242
+ connection,
243
+ sourceTypes,
244
+ cursor: latestJob?.cursor || {},
245
+ });
246
+ } catch (err) {
247
+ this._recordCollectorFailure(connection, primarySource, policy, agentId, err);
248
+ throw err;
249
+ }
339
250
  return this.ingestDocuments(connection.user_id, collected.documents || [], {
340
251
  agentId,
341
252
  sourceType: primarySource,
@@ -373,107 +284,11 @@ class MemoryIngestionService {
373
284
  }
374
285
 
375
286
  listConnectionStatuses(userId, { agentId = null } = {}) {
376
- const scopedAgentId = resolveAgentId(userId, agentId);
377
- const connections = db.prepare(
378
- `SELECT *
379
- FROM integration_connections
380
- WHERE user_id = ? AND agent_id = ?
381
- ORDER BY updated_at DESC, id DESC`,
382
- ).all(userId, scopedAgentId);
383
- const jobs = this.memoryManager.listIngestionJobs(userId, { agentId: scopedAgentId, limit: 100 });
384
- return connections.map((connection) => {
385
- const latestJob = jobs.find((job) => Number(job.connectionId || 0) === Number(connection.id));
386
- return {
387
- connectionId: connection.id,
388
- providerKey: connection.provider_key,
389
- appKey: connection.app_key,
390
- accountEmail: connection.account_email || null,
391
- ...buildCoverageForConnection(connection, latestJob),
392
- };
393
- });
287
+ return listConnectionStatuses(this, userId, { agentId });
394
288
  }
395
289
 
396
290
  decorateProviderSnapshot(snapshot, userId, agentId = null) {
397
- if (!snapshot || typeof snapshot !== 'object') return snapshot;
398
- const scopedAgentId = resolveAgentId(userId, agentId);
399
- const connections = db.prepare(
400
- `SELECT *
401
- FROM integration_connections
402
- WHERE user_id = ? AND agent_id = ? AND provider_key = ?`,
403
- ).all(userId, scopedAgentId, snapshot.provider);
404
- const jobs = this.memoryManager.listIngestionJobs(userId, {
405
- agentId: scopedAgentId,
406
- providerKey: snapshot.provider,
407
- limit: 100,
408
- });
409
- const latestJobForConnection = (connectionId) =>
410
- jobs.find((job) => Number(job.connectionId || 0) === Number(connectionId));
411
-
412
- const connectionCoverage = connections.map((connection) => ({
413
- connectionId: connection.id,
414
- appKey: connection.app_key,
415
- accountEmail: connection.account_email || null,
416
- ...buildCoverageForConnection(connection, latestJobForConnection(connection.id)),
417
- }));
418
- const providerDomains = Array.from(new Set(connectionCoverage.flatMap((item) => item.dataDomains || [])));
419
- const providerJob = jobs[0] || null;
420
- const decorated = {
421
- ...snapshot,
422
- memoryCoverage: {
423
- supported: providerDomains.length > 0,
424
- contributesToMemory: providerDomains.length > 0,
425
- contributesToTaskExecution: Boolean(snapshot.connection?.connected),
426
- status: providerJob?.status || (providerDomains.length > 0 ? 'ready' : 'not_supported'),
427
- dataDomains: providerDomains,
428
- documentCount: connectionCoverage.reduce((sum, item) => sum + Number(item.documentCount || 0), 0),
429
- lastRefreshAt: providerJob?.completedAt || providerJob?.updatedAt || null,
430
- nextRefreshAt: providerJob?.nextSyncAt || null,
431
- error: providerJob?.error || null,
432
- },
433
- };
434
-
435
- decorated.apps = (snapshot.apps || []).map((app) => {
436
- const appConnections = connectionCoverage.filter((item) => item.appKey === app.id);
437
- const dataDomains = Array.from(new Set([
438
- ...sourceTypesForConnection(snapshot.provider, app.id),
439
- ...appConnections.flatMap((item) => item.dataDomains || []),
440
- ]));
441
- const latest = appConnections[0] || null;
442
- return {
443
- ...app,
444
- memoryCoverage: {
445
- supported: dataDomains.length > 0,
446
- contributesToMemory: dataDomains.length > 0 && app.connection?.connected === true,
447
- contributesToTaskExecution: app.connection?.connected === true,
448
- status: latest?.status || (dataDomains.length > 0 ? 'ready' : 'not_supported'),
449
- dataDomains,
450
- documentCount: appConnections.reduce((sum, item) => sum + Number(item.documentCount || 0), 0),
451
- lastRefreshAt: latest?.lastRefreshAt || null,
452
- nextRefreshAt: latest?.nextRefreshAt || null,
453
- error: latest?.error || null,
454
- },
455
- accounts: (app.accounts || []).map((account) => {
456
- const accountCoverage = appConnections.find((item) =>
457
- Number(item.connectionId) === Number(account.connectionId),
458
- );
459
- return {
460
- ...account,
461
- memoryCoverage: accountCoverage || {
462
- supported: dataDomains.length > 0,
463
- contributesToMemory: false,
464
- contributesToTaskExecution: app.connection?.connected === true,
465
- status: dataDomains.length > 0 ? 'ready' : 'not_supported',
466
- dataDomains,
467
- documentCount: 0,
468
- lastRefreshAt: null,
469
- nextRefreshAt: null,
470
- error: null,
471
- },
472
- };
473
- }),
474
- };
475
- });
476
- return decorated;
291
+ return decorateProviderSnapshot(this, snapshot, userId, agentId);
477
292
  }
478
293
  }
479
294