neoagent 3.0.1-beta.9 → 3.1.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 (97) hide show
  1. package/.env.example +2 -10
  2. package/README.md +12 -3
  3. package/docs/automation.md +37 -0
  4. package/docs/benchmarking.md +102 -0
  5. package/docs/billing.md +34 -8
  6. package/docs/configuration.md +11 -2
  7. package/docs/getting-started.md +10 -8
  8. package/docs/operations.md +1 -1
  9. package/flutter_app/lib/main.dart +3 -0
  10. package/flutter_app/lib/main_account_settings.dart +138 -0
  11. package/flutter_app/lib/main_app_shell.dart +38 -13
  12. package/flutter_app/lib/main_billing.dart +1465 -0
  13. package/flutter_app/lib/main_chat.dart +1612 -214
  14. package/flutter_app/lib/main_controller.dart +398 -26
  15. package/flutter_app/lib/main_devices.dart +293 -207
  16. package/flutter_app/lib/main_models.dart +142 -0
  17. package/flutter_app/lib/main_navigation.dart +19 -1
  18. package/flutter_app/lib/main_operations.dart +288 -9
  19. package/flutter_app/lib/main_settings.dart +510 -300
  20. package/flutter_app/lib/main_shared.dart +2 -0
  21. package/flutter_app/lib/main_timeline.dart +1378 -0
  22. package/flutter_app/lib/src/backend_client.dart +149 -19
  23. package/flutter_app/lib/src/desktop_companion_actions.dart +132 -21
  24. package/flutter_app/lib/src/desktop_companion_io.dart +65 -1
  25. package/flutter_app/lib/src/desktop_companion_stub.dart +12 -0
  26. package/flutter_app/lib/src/desktop_ocr_bridge.dart +2 -0
  27. package/flutter_app/lib/src/desktop_ocr_bridge_io.dart +125 -0
  28. package/flutter_app/lib/src/desktop_ocr_bridge_stub.dart +30 -0
  29. package/flutter_app/lib/src/desktop_passive_history.dart +332 -0
  30. package/flutter_app/lib/src/recording_bridge_io.dart +80 -72
  31. package/flutter_app/lib/src/recording_bridge_web.dart +127 -114
  32. package/flutter_app/lib/src/recording_chunk_queue.dart +149 -0
  33. package/flutter_app/lib/src/recording_chunk_queue_io.dart +182 -0
  34. package/flutter_app/lib/src/recording_payloads.dart +9 -0
  35. package/flutter_app/macos/Runner/AppDelegate.swift +25 -0
  36. package/flutter_app/windows/runner/flutter_window.cpp +75 -0
  37. package/landing/index.html +3 -3
  38. package/lib/manager.js +184 -66
  39. package/package.json +4 -1
  40. package/server/admin/access.js +12 -7
  41. package/server/admin/admin.js +436 -10
  42. package/server/admin/billing.js +158 -35
  43. package/server/admin/index.html +72 -2
  44. package/server/admin/users.js +15 -15
  45. package/server/db/database.js +125 -20
  46. package/server/http/routes.js +1 -0
  47. package/server/http/static.js +4 -2
  48. package/server/public/.last_build_id +1 -1
  49. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  50. package/server/public/canvaskit/wimp.js.symbols +8475 -8467
  51. package/server/public/canvaskit/wimp.wasm +0 -0
  52. package/server/public/flutter_bootstrap.js +2 -2
  53. package/server/public/main.dart.js +91077 -87037
  54. package/server/routes/account.js +53 -0
  55. package/server/routes/admin.js +345 -64
  56. package/server/routes/agents.js +203 -21
  57. package/server/routes/billing.js +5 -0
  58. package/server/routes/browser.js +8 -1
  59. package/server/routes/recordings.js +96 -6
  60. package/server/routes/screenHistory.js +140 -2
  61. package/server/routes/timeline.js +43 -0
  62. package/server/services/account/erasure.js +263 -0
  63. package/server/services/ai/hooks.js +4 -1
  64. package/server/services/ai/loop/agent_engine_core.js +8 -1
  65. package/server/services/ai/loop/blank_recovery.js +36 -0
  66. package/server/services/ai/loop/conversation_loop.js +166 -33
  67. package/server/services/ai/messagingFallback.js +22 -0
  68. package/server/services/ai/rate_limits.js +28 -5
  69. package/server/services/ai/systemPrompt.js +6 -5
  70. package/server/services/ai/taskAnalysis.js +2 -0
  71. package/server/services/ai/toolEvidence.js +15 -0
  72. package/server/services/ai/toolResult.js +40 -0
  73. package/server/services/ai/tools.js +163 -4
  74. package/server/services/android/controller.js +6 -2
  75. package/server/services/billing/plans.js +2 -1
  76. package/server/services/browser/anti_detection.js +192 -0
  77. package/server/services/browser/controller.js +180 -54
  78. package/server/services/desktop/auth.js +3 -0
  79. package/server/services/desktop/registry.js +50 -2
  80. package/server/services/integrations/google/calendar.js +22 -14
  81. package/server/services/manager.js +12 -42
  82. package/server/services/memory/ingestion_chunking.js +268 -0
  83. package/server/services/messaging/telnyx.js +9 -8
  84. package/server/services/recordings/manager.js +60 -27
  85. package/server/services/runtime/docker-vm-manager.js +157 -266
  86. package/server/services/runtime/guest_bootstrap.js +17 -5
  87. package/server/services/runtime/guest_image.js +188 -0
  88. package/server/services/runtime/manager.js +0 -1
  89. package/server/services/runtime/validation.js +3 -8
  90. package/server/services/social_video/service.js +60 -10
  91. package/server/services/tasks/runtime.js +234 -9
  92. package/server/services/tasks/task_repository.js +13 -0
  93. package/server/services/timeline/service.js +558 -0
  94. package/server/services/wearable/gateway.js +1 -1
  95. package/server/services/websocket.js +21 -3
  96. package/server/services/desktop/screenRecorder.js +0 -292
  97. package/server/services/desktop/screen_recorder_support.js +0 -46
@@ -0,0 +1,558 @@
1
+ 'use strict';
2
+
3
+ const db = require('../../db/database');
4
+
5
+ const DEFAULT_SCREEN_SESSION_GAP_MS = 5 * 60 * 1000;
6
+ const DEFAULT_FEED_LIMIT = 50;
7
+ const MAX_FEED_LIMIT = 200;
8
+ const DEFAULT_PROMPT_CONTEXT_LIMIT = 6;
9
+ const MAX_PROMPT_CONTEXT_LIMIT = 12;
10
+
11
+ function parseJson(value, fallback = {}) {
12
+ if (!value) return { ...fallback };
13
+ if (typeof value === 'object' && !Array.isArray(value)) return { ...value };
14
+ try {
15
+ const parsed = JSON.parse(String(value));
16
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
17
+ ? parsed
18
+ : { ...fallback };
19
+ } catch {
20
+ return { ...fallback };
21
+ }
22
+ }
23
+
24
+ function safeJson(value) {
25
+ try {
26
+ return JSON.stringify(value || {});
27
+ } catch {
28
+ return '{}';
29
+ }
30
+ }
31
+
32
+ function normalizeTimestamp(value, fallback = new Date().toISOString()) {
33
+ const text = String(value || '').trim();
34
+ if (!text) return fallback;
35
+ const parsed = new Date(text);
36
+ return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : fallback;
37
+ }
38
+
39
+ function normalizeText(value, maxLength = 1000) {
40
+ const text = String(value || '').replace(/\s+/g, ' ').trim();
41
+ if (!text) return '';
42
+ return text.length > maxLength ? text.slice(0, maxLength) : text;
43
+ }
44
+
45
+ function queryTerms(value) {
46
+ return Array.from(new Set(
47
+ normalizeText(value, 240)
48
+ .toLowerCase()
49
+ .split(/[^a-z0-9]+/i)
50
+ .map((term) => term.trim())
51
+ .filter((term) => term.length >= 4)
52
+ ));
53
+ }
54
+
55
+ function formatPromptTimestamp(value) {
56
+ const parsed = new Date(String(value || ''));
57
+ if (!Number.isFinite(parsed.getTime())) {
58
+ return String(value || '').slice(0, 16);
59
+ }
60
+ return parsed.toISOString().slice(0, 16).replace('T', ' ');
61
+ }
62
+
63
+ function toFeedItem(row) {
64
+ return {
65
+ id: Number(row.id),
66
+ sourceKind: String(row.source_kind || ''),
67
+ eventKind: String(row.event_kind || ''),
68
+ occurredAt: row.occurred_at,
69
+ title: String(row.title || ''),
70
+ summary: String(row.summary || ''),
71
+ agentId: row.agent_id || null,
72
+ sourceId: row.source_id || null,
73
+ metadata: parseJson(row.metadata_json),
74
+ };
75
+ }
76
+
77
+ class TimelineService {
78
+ constructor(options = {}) {
79
+ this.db = options.db || db;
80
+ this.io = options.io || null;
81
+ this.screenSessionGapMs = Number(
82
+ options.screenSessionGapMs || DEFAULT_SCREEN_SESSION_GAP_MS,
83
+ );
84
+ }
85
+
86
+ _emitUpdated(userId) {
87
+ if (!userId || !this.io?.to) return;
88
+ this.io.to(`user:${userId}`).emit('timeline:updated', {
89
+ timestamp: new Date().toISOString(),
90
+ });
91
+ }
92
+
93
+ _insertEvent({
94
+ userId,
95
+ agentId = null,
96
+ sourceKind,
97
+ eventKind,
98
+ occurredAt,
99
+ title,
100
+ summary = '',
101
+ sourceId = null,
102
+ groupKey = null,
103
+ metadata = {},
104
+ }) {
105
+ const normalizedOccurredAt = normalizeTimestamp(occurredAt);
106
+ const result = this.db.prepare(
107
+ `INSERT INTO timeline_events (
108
+ user_id, agent_id, source_kind, event_kind, occurred_at, title, summary, source_id, group_key, metadata_json
109
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
110
+ ).run(
111
+ userId,
112
+ agentId || null,
113
+ String(sourceKind || '').trim(),
114
+ String(eventKind || '').trim(),
115
+ normalizedOccurredAt,
116
+ normalizeText(title, 240),
117
+ normalizeText(summary, 1000),
118
+ sourceId == null ? null : String(sourceId),
119
+ groupKey == null ? null : String(groupKey),
120
+ safeJson(metadata),
121
+ );
122
+ const row = this.db.prepare(
123
+ `SELECT id, user_id, agent_id, source_kind, event_kind, occurred_at, title, summary, source_id, group_key, metadata_json
124
+ FROM timeline_events
125
+ WHERE id = ?`
126
+ ).get(result.lastInsertRowid);
127
+ return row ? toFeedItem(row) : null;
128
+ }
129
+
130
+ listEvents(userId, options = {}) {
131
+ const normalizedUserId = Number(userId);
132
+ const limitRaw = Number.parseInt(options.limit, 10);
133
+ const limit = Number.isFinite(limitRaw) && limitRaw > 0
134
+ ? Math.min(limitRaw, MAX_FEED_LIMIT)
135
+ : DEFAULT_FEED_LIMIT;
136
+ const beforeOccurredAt = String(options.beforeOccurredAt || '').trim();
137
+ const beforeId = Number.parseInt(options.beforeId, 10);
138
+ const agentId = String(options.agentId || '').trim();
139
+ const sources = Array.isArray(options.sources)
140
+ ? options.sources
141
+ .map((value) => String(value || '').trim())
142
+ .filter(Boolean)
143
+ : [];
144
+
145
+ const clauses = ['user_id = ?'];
146
+ const params = [normalizedUserId];
147
+
148
+ if (agentId) {
149
+ clauses.push('agent_id = ?');
150
+ params.push(agentId);
151
+ }
152
+ if (sources.length > 0) {
153
+ clauses.push(`source_kind IN (${sources.map(() => '?').join(', ')})`);
154
+ params.push(...sources);
155
+ }
156
+ if (beforeOccurredAt && Number.isFinite(beforeId) && beforeId > 0) {
157
+ clauses.push('(occurred_at < ? OR (occurred_at = ? AND id < ?))');
158
+ params.push(beforeOccurredAt, beforeOccurredAt, beforeId);
159
+ }
160
+
161
+ const rows = this.db.prepare(
162
+ `SELECT id, user_id, agent_id, source_kind, event_kind, occurred_at, title, summary, source_id, group_key, metadata_json
163
+ FROM timeline_events
164
+ WHERE ${clauses.join(' AND ')}
165
+ ORDER BY occurred_at DESC, id DESC
166
+ LIMIT ?`
167
+ ).all(...params, limit);
168
+ return rows.map(toFeedItem);
169
+ }
170
+
171
+ buildPromptContext(userId, options = {}) {
172
+ const limitRaw = Number.parseInt(options.limit, 10);
173
+ const limit = Number.isFinite(limitRaw) && limitRaw > 0
174
+ ? Math.min(limitRaw, MAX_PROMPT_CONTEXT_LIMIT)
175
+ : DEFAULT_PROMPT_CONTEXT_LIMIT;
176
+ const sources = Array.isArray(options.sources) && options.sources.length > 0
177
+ ? options.sources
178
+ : ['screen', 'tasks', 'runs'];
179
+ const query = String(options.query || '').trim();
180
+ const preferAgentId = String(options.agentId || '').trim();
181
+ const candidates = this.listEvents(userId, {
182
+ limit: Math.max(limit * 3, limit),
183
+ sources,
184
+ });
185
+ if (candidates.length === 0) {
186
+ return '';
187
+ }
188
+
189
+ const terms = queryTerms(query);
190
+ const scored = candidates.map((item, index) => {
191
+ const metadata = item.metadata && typeof item.metadata === 'object'
192
+ ? item.metadata
193
+ : {};
194
+ const haystack = [
195
+ item.sourceKind,
196
+ item.eventKind,
197
+ item.title,
198
+ item.summary,
199
+ metadata.appName,
200
+ metadata.windowTitle,
201
+ metadata.deviceLabel,
202
+ metadata.previewText,
203
+ metadata.taskName,
204
+ ].map((value) => normalizeText(value, 400).toLowerCase())
205
+ .filter(Boolean)
206
+ .join(' ');
207
+ const matchCount = terms.reduce(
208
+ (count, term) => count + (haystack.includes(term) ? 1 : 0),
209
+ 0,
210
+ );
211
+ const preferredAgent = preferAgentId && item.agentId === preferAgentId ? 1 : 0;
212
+ return {
213
+ item,
214
+ index,
215
+ score: matchCount + preferredAgent,
216
+ };
217
+ });
218
+
219
+ const relevant = (terms.length > 0
220
+ ? scored
221
+ .sort((left, right) => (
222
+ right.score - left.score
223
+ || left.index - right.index
224
+ ))
225
+ .filter((entry) => entry.score > 0)
226
+ : scored
227
+ );
228
+ const selectedEntries = (relevant.length > 0 ? relevant : scored)
229
+ .slice(0, limit);
230
+ const selected = selectedEntries
231
+ .map((entry) => entry.item)
232
+ .sort((left, right) => left.occurredAt.localeCompare(right.occurredAt));
233
+
234
+ if (selected.length === 0) {
235
+ return '';
236
+ }
237
+
238
+ const lines = selected.map((item) => this._formatPromptLine(item));
239
+ return [
240
+ 'Recent timeline context (user-wide, recent activity only; use as soft situational context, not as authoritative proof):',
241
+ ...lines,
242
+ ].join('\n');
243
+ }
244
+
245
+ recordTaskLifecycle({
246
+ userId,
247
+ agentId = null,
248
+ taskId,
249
+ taskName,
250
+ eventKind,
251
+ occurredAt = null,
252
+ runId = null,
253
+ reason = null,
254
+ error = null,
255
+ triggerType = null,
256
+ triggerSource = null,
257
+ }) {
258
+ const normalizedTaskId = Number.parseInt(taskId, 10);
259
+ const normalizedName = normalizeText(taskName || `Task ${normalizedTaskId}`, 180)
260
+ || `Task ${normalizedTaskId}`;
261
+ const summary = normalizeText(error || reason || triggerType || '', 240);
262
+ const item = this._insertEvent({
263
+ userId,
264
+ agentId,
265
+ sourceKind: 'tasks',
266
+ eventKind,
267
+ occurredAt: occurredAt || new Date().toISOString(),
268
+ title: normalizedName,
269
+ summary,
270
+ sourceId: Number.isFinite(normalizedTaskId) ? String(normalizedTaskId) : null,
271
+ metadata: {
272
+ taskId: Number.isFinite(normalizedTaskId) ? normalizedTaskId : null,
273
+ taskName: normalizedName,
274
+ runId: runId || null,
275
+ reason: reason || null,
276
+ error: error || null,
277
+ triggerType: triggerType || null,
278
+ triggerSource: triggerSource || null,
279
+ },
280
+ });
281
+ this._emitUpdated(userId);
282
+ return item;
283
+ }
284
+
285
+ recordRunLifecycle({
286
+ userId,
287
+ agentId = null,
288
+ runId,
289
+ title,
290
+ eventKind,
291
+ occurredAt = null,
292
+ status = null,
293
+ triggerSource = null,
294
+ error = null,
295
+ }) {
296
+ const normalizedTitle = normalizeText(title || 'Untitled run', 180) || 'Untitled run';
297
+ const summary = normalizeText(error || status || triggerSource || '', 240);
298
+ const item = this._insertEvent({
299
+ userId,
300
+ agentId,
301
+ sourceKind: 'runs',
302
+ eventKind,
303
+ occurredAt: occurredAt || new Date().toISOString(),
304
+ title: normalizedTitle,
305
+ summary,
306
+ sourceId: runId ? String(runId) : null,
307
+ metadata: {
308
+ runId: runId || null,
309
+ title: normalizedTitle,
310
+ status: status || null,
311
+ triggerSource: triggerSource || null,
312
+ error: error || null,
313
+ },
314
+ });
315
+ this._emitUpdated(userId);
316
+ return item;
317
+ }
318
+
319
+ storeScreenEntries({
320
+ userId,
321
+ deviceId,
322
+ deviceLabel = '',
323
+ entries = [],
324
+ ocrEngine = 'local_tesseract',
325
+ }) {
326
+ const normalizedDeviceId = String(deviceId || '').trim();
327
+ const normalizedDeviceLabel = normalizeText(deviceLabel, 180);
328
+ const normalizedEntries = Array.isArray(entries)
329
+ ? entries
330
+ .map((entry) => ({
331
+ capturedAt: normalizeTimestamp(entry?.capturedAt),
332
+ appName: normalizeText(entry?.frontmostApp || entry?.appName, 180),
333
+ windowTitle: normalizeText(entry?.windowTitle, 240),
334
+ text: normalizeText(entry?.text, 8000),
335
+ ocrConfidence: Number.isFinite(Number(entry?.ocrConfidence))
336
+ ? Number(entry.ocrConfidence)
337
+ : null,
338
+ }))
339
+ .filter((entry) => entry.text.length > 0)
340
+ .sort((left, right) => left.capturedAt.localeCompare(right.capturedAt))
341
+ : [];
342
+
343
+ if (!normalizedDeviceId) {
344
+ throw new Error('deviceId is required.');
345
+ }
346
+ if (normalizedEntries.length === 0) {
347
+ return { insertedCount: 0, timelineItems: [] };
348
+ }
349
+
350
+ const batchDedupKeys = new Set();
351
+ const findExistingEntry = this.db.prepare(
352
+ `SELECT id
353
+ FROM screen_history
354
+ WHERE user_id = ?
355
+ AND captured_at = ?
356
+ AND device_id = ?
357
+ AND COALESCE(app_name, '') = ?
358
+ AND COALESCE(window_title, '') = ?
359
+ AND text_content = ?
360
+ LIMIT 1`
361
+ );
362
+
363
+ const tx = this.db.transaction(() => {
364
+ const timelineItems = [];
365
+ let insertedCount = 0;
366
+ for (const entry of normalizedEntries) {
367
+ const dedupKey = JSON.stringify([
368
+ entry.capturedAt,
369
+ normalizedDeviceId,
370
+ entry.appName,
371
+ entry.windowTitle,
372
+ entry.text,
373
+ ]);
374
+ if (batchDedupKeys.has(dedupKey)) {
375
+ continue;
376
+ }
377
+ batchDedupKeys.add(dedupKey);
378
+ const existing = findExistingEntry.get(
379
+ userId,
380
+ entry.capturedAt,
381
+ normalizedDeviceId,
382
+ entry.appName || '',
383
+ entry.windowTitle || '',
384
+ entry.text,
385
+ );
386
+ if (existing) {
387
+ continue;
388
+ }
389
+ const insert = this.db.prepare(
390
+ `INSERT INTO screen_history (
391
+ user_id, timestamp, captured_at, device_id, device_label, app_name, window_title, text_content, ocr_engine, ocr_confidence
392
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
393
+ ).run(
394
+ userId,
395
+ entry.capturedAt,
396
+ entry.capturedAt,
397
+ normalizedDeviceId,
398
+ normalizedDeviceLabel || null,
399
+ entry.appName || null,
400
+ entry.windowTitle || null,
401
+ entry.text,
402
+ ocrEngine,
403
+ entry.ocrConfidence,
404
+ );
405
+ insertedCount += 1;
406
+ const screenHistoryId = Number(insert.lastInsertRowid);
407
+ const timelineItem = this._upsertScreenSession({
408
+ userId,
409
+ deviceId: normalizedDeviceId,
410
+ deviceLabel: normalizedDeviceLabel,
411
+ screenHistoryId,
412
+ capturedAt: entry.capturedAt,
413
+ appName: entry.appName,
414
+ windowTitle: entry.windowTitle,
415
+ text: entry.text,
416
+ ocrConfidence: entry.ocrConfidence,
417
+ });
418
+ if (timelineItem) {
419
+ timelineItems.push(timelineItem);
420
+ }
421
+ }
422
+ return {
423
+ insertedCount,
424
+ timelineItems,
425
+ };
426
+ });
427
+
428
+ const result = tx();
429
+ this._emitUpdated(userId);
430
+ return result;
431
+ }
432
+
433
+ _upsertScreenSession({
434
+ userId,
435
+ deviceId,
436
+ deviceLabel,
437
+ screenHistoryId,
438
+ capturedAt,
439
+ appName,
440
+ windowTitle,
441
+ text,
442
+ ocrConfidence,
443
+ }) {
444
+ const groupKey = JSON.stringify([
445
+ String(deviceId || '').trim(),
446
+ normalizeText(appName, 180),
447
+ normalizeText(windowTitle, 240),
448
+ ]);
449
+ const latest = this.db.prepare(
450
+ `SELECT id, occurred_at, title, summary, group_key, metadata_json
451
+ FROM timeline_events
452
+ WHERE user_id = ? AND source_kind = 'screen'
453
+ ORDER BY occurred_at DESC, id DESC
454
+ LIMIT 1`
455
+ ).get(userId);
456
+ const previewText = normalizeText(text, 280);
457
+ const title = windowTitle
458
+ ? `${appName || 'Screen activity'} · ${windowTitle}`
459
+ : (appName || 'Screen activity');
460
+ const summary = previewText || (deviceLabel || deviceId);
461
+ const occurredMs = new Date(capturedAt).getTime();
462
+
463
+ if (latest && latest.group_key === groupKey) {
464
+ const latestMs = new Date(String(latest.occurred_at || '')).getTime();
465
+ if (
466
+ Number.isFinite(occurredMs)
467
+ && Number.isFinite(latestMs)
468
+ && (occurredMs - latestMs) <= this.screenSessionGapMs
469
+ ) {
470
+ const metadata = parseJson(latest.metadata_json, {});
471
+ const entryCount = Number(metadata.entryCount || 0) + 1;
472
+ const nextMetadata = {
473
+ ...metadata,
474
+ deviceId,
475
+ deviceLabel: deviceLabel || metadata.deviceLabel || null,
476
+ appName: appName || metadata.appName || null,
477
+ windowTitle: windowTitle || metadata.windowTitle || null,
478
+ startedAt: metadata.startedAt || capturedAt,
479
+ endedAt: capturedAt,
480
+ entryCount,
481
+ previewText: previewText || metadata.previewText || '',
482
+ lastScreenHistoryId: screenHistoryId,
483
+ lastOcrConfidence: ocrConfidence,
484
+ };
485
+ this.db.prepare(
486
+ `UPDATE timeline_events
487
+ SET occurred_at = ?, title = ?, summary = ?, source_id = ?, metadata_json = ?
488
+ WHERE id = ?`
489
+ ).run(
490
+ capturedAt,
491
+ normalizeText(title, 240),
492
+ normalizeText(summary, 1000),
493
+ String(screenHistoryId),
494
+ safeJson(nextMetadata),
495
+ latest.id,
496
+ );
497
+ const row = this.db.prepare(
498
+ `SELECT id, user_id, agent_id, source_kind, event_kind, occurred_at, title, summary, source_id, group_key, metadata_json
499
+ FROM timeline_events
500
+ WHERE id = ?`
501
+ ).get(latest.id);
502
+ return row ? toFeedItem(row) : null;
503
+ }
504
+ }
505
+
506
+ return this._insertEvent({
507
+ userId,
508
+ sourceKind: 'screen',
509
+ eventKind: 'screen_session',
510
+ occurredAt: capturedAt,
511
+ title,
512
+ summary,
513
+ sourceId: String(screenHistoryId),
514
+ groupKey,
515
+ metadata: {
516
+ deviceId,
517
+ deviceLabel: deviceLabel || null,
518
+ appName: appName || null,
519
+ windowTitle: windowTitle || null,
520
+ startedAt: capturedAt,
521
+ endedAt: capturedAt,
522
+ entryCount: 1,
523
+ previewText,
524
+ lastScreenHistoryId: screenHistoryId,
525
+ lastOcrConfidence: ocrConfidence,
526
+ },
527
+ });
528
+ }
529
+
530
+ _formatPromptLine(item) {
531
+ const metadata = item.metadata && typeof item.metadata === 'object'
532
+ ? item.metadata
533
+ : {};
534
+ const when = formatPromptTimestamp(item.occurredAt);
535
+ if (item.sourceKind === 'screen') {
536
+ const detail = [
537
+ metadata.deviceLabel || metadata.deviceId || 'Desktop',
538
+ metadata.appName || 'Unknown app',
539
+ metadata.windowTitle || null,
540
+ ].filter(Boolean).join(' · ');
541
+ const preview = normalizeText(metadata.previewText || item.summary, 160);
542
+ return `- [screen ${when}] ${detail}${preview ? ` — ${preview}` : ''}`;
543
+ }
544
+ if (item.sourceKind === 'tasks') {
545
+ const taskName = normalizeText(metadata.taskName || item.title, 120) || item.title;
546
+ const runId = normalizeText(metadata.runId, 80);
547
+ const summary = normalizeText(item.summary, 140);
548
+ return `- [task ${when}] ${taskName} · ${item.eventKind}${runId ? ` · run ${runId}` : ''}${summary ? ` — ${summary}` : ''}`;
549
+ }
550
+ const runTitle = normalizeText(item.title, 120) || 'Untitled run';
551
+ const summary = normalizeText(item.summary, 140);
552
+ return `- [run ${when}] ${runTitle} · ${item.eventKind}${summary ? ` — ${summary}` : ''}`;
553
+ }
554
+ }
555
+
556
+ module.exports = {
557
+ TimelineService,
558
+ };
@@ -145,7 +145,7 @@ function createWearableVoiceSink(ws, voiceRuntimeManager) {
145
145
  }
146
146
 
147
147
  function bindWearableGateway(httpServer, app, sessionMiddleware) {
148
- const wss = new WebSocketServer({ noServer: true });
148
+ const wss = new WebSocketServer({ noServer: true, maxPayload: 1 * 1024 * 1024 });
149
149
  const allowUpgradeAttempt = createUpgradeLimiter();
150
150
 
151
151
  httpServer.on('upgrade', (req, socket, head) => {
@@ -15,6 +15,11 @@ const MAX_QUERY_CHARS = 2000;
15
15
  const DEFAULT_RATE_LIMIT_WINDOW_MS = 10 * 1000;
16
16
  const DEFAULT_RATE_LIMIT_MAX = 30;
17
17
  const RATE_LIMIT_OBSERVER_ENTRY_TTL_MS = 10 * 60 * 1000;
18
+ // Shared rate-limit state keyed by userId so that multiple sockets from the
19
+ // same authenticated user share a single budget rather than getting N × budget.
20
+ const userRateLimitStates = new Map();
21
+ const userSocketCounts = new Map();
22
+
18
23
  const EVENT_RATE_LIMITS = Object.freeze({
19
24
  'agent:run': { windowMs: 15 * 1000, max: 4 },
20
25
  'agent:abort': { windowMs: 10 * 1000, max: 20 },
@@ -74,8 +79,7 @@ function resolveAgentFromPayload(userId, value) {
74
79
  return resolveAgentId(userId, data?.agentId || data?.agent_id || null);
75
80
  }
76
81
 
77
- function createSocketRateLimiter() {
78
- const state = new Map();
82
+ function createSocketRateLimiter(state = new Map()) {
79
83
  return (eventName) => {
80
84
  const config = EVENT_RATE_LIMITS[eventName] || {
81
85
  windowMs: DEFAULT_RATE_LIMIT_WINDOW_MS,
@@ -184,7 +188,6 @@ function setupWebSocket(io, services) {
184
188
  services.app.locals.getWebsocketRateLimitSnapshot = () => rateLimitObserver.snapshot();
185
189
  }
186
190
  io.on('connection', (socket) => {
187
- const allowEvent = createSocketRateLimiter();
188
191
  const session = socket.request.session;
189
192
  if (!session?.userId) {
190
193
  console.warn(`[WS] Rejecting unauthenticated socket ${socket.id}`);
@@ -195,6 +198,13 @@ function setupWebSocket(io, services) {
195
198
  const userId = session.userId;
196
199
  socket.join(`user:${userId}`);
197
200
 
201
+ // Share rate-limit budget across all sockets from the same authenticated user.
202
+ userSocketCounts.set(userId, (userSocketCounts.get(userId) || 0) + 1);
203
+ if (!userRateLimitStates.has(userId)) {
204
+ userRateLimitStates.set(userId, new Map());
205
+ }
206
+ const allowEvent = createSocketRateLimiter(userRateLimitStates.get(userId));
207
+
198
208
  console.log(`[WS] User ${userId} connected (${socket.id})`);
199
209
 
200
210
  // ── Agent Events ──
@@ -911,6 +921,14 @@ function setupWebSocket(io, services) {
911
921
  // ── Disconnect ──
912
922
 
913
923
  socket.on('disconnect', () => {
924
+ const remaining = (userSocketCounts.get(userId) || 1) - 1;
925
+ if (remaining <= 0) {
926
+ userSocketCounts.delete(userId);
927
+ userRateLimitStates.delete(userId);
928
+ } else {
929
+ userSocketCounts.set(userId, remaining);
930
+ }
931
+
914
932
  const streamHub = services.streamHub || services.app?.locals?.streamHub;
915
933
  if (streamHub && typeof streamHub.unsubscribeAll === 'function') {
916
934
  void streamHub.unsubscribeAll(socket.id).catch((err) => {