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.
- package/.env.example +2 -10
- package/README.md +12 -3
- package/docs/automation.md +37 -0
- package/docs/benchmarking.md +102 -0
- package/docs/billing.md +34 -8
- package/docs/configuration.md +11 -2
- package/docs/getting-started.md +10 -8
- package/docs/operations.md +1 -1
- package/flutter_app/lib/main.dart +3 -0
- package/flutter_app/lib/main_account_settings.dart +138 -0
- package/flutter_app/lib/main_app_shell.dart +38 -13
- package/flutter_app/lib/main_billing.dart +1465 -0
- package/flutter_app/lib/main_chat.dart +1612 -214
- package/flutter_app/lib/main_controller.dart +398 -26
- package/flutter_app/lib/main_devices.dart +293 -207
- package/flutter_app/lib/main_models.dart +142 -0
- package/flutter_app/lib/main_navigation.dart +19 -1
- package/flutter_app/lib/main_operations.dart +288 -9
- package/flutter_app/lib/main_settings.dart +510 -300
- package/flutter_app/lib/main_shared.dart +2 -0
- package/flutter_app/lib/main_timeline.dart +1378 -0
- package/flutter_app/lib/src/backend_client.dart +149 -19
- package/flutter_app/lib/src/desktop_companion_actions.dart +132 -21
- package/flutter_app/lib/src/desktop_companion_io.dart +65 -1
- package/flutter_app/lib/src/desktop_companion_stub.dart +12 -0
- package/flutter_app/lib/src/desktop_ocr_bridge.dart +2 -0
- package/flutter_app/lib/src/desktop_ocr_bridge_io.dart +125 -0
- package/flutter_app/lib/src/desktop_ocr_bridge_stub.dart +30 -0
- package/flutter_app/lib/src/desktop_passive_history.dart +332 -0
- package/flutter_app/lib/src/recording_bridge_io.dart +80 -72
- package/flutter_app/lib/src/recording_bridge_web.dart +127 -114
- package/flutter_app/lib/src/recording_chunk_queue.dart +149 -0
- package/flutter_app/lib/src/recording_chunk_queue_io.dart +182 -0
- package/flutter_app/lib/src/recording_payloads.dart +9 -0
- package/flutter_app/macos/Runner/AppDelegate.swift +25 -0
- package/flutter_app/windows/runner/flutter_window.cpp +75 -0
- package/landing/index.html +3 -3
- package/lib/manager.js +184 -66
- package/package.json +4 -1
- package/server/admin/access.js +12 -7
- package/server/admin/admin.js +436 -10
- package/server/admin/billing.js +158 -35
- package/server/admin/index.html +72 -2
- package/server/admin/users.js +15 -15
- package/server/db/database.js +125 -20
- package/server/http/routes.js +1 -0
- package/server/http/static.js +4 -2
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/canvaskit/wimp.js.symbols +8475 -8467
- package/server/public/canvaskit/wimp.wasm +0 -0
- package/server/public/flutter_bootstrap.js +2 -2
- package/server/public/main.dart.js +91077 -87037
- package/server/routes/account.js +53 -0
- package/server/routes/admin.js +345 -64
- package/server/routes/agents.js +203 -21
- package/server/routes/billing.js +5 -0
- package/server/routes/browser.js +8 -1
- package/server/routes/recordings.js +96 -6
- package/server/routes/screenHistory.js +140 -2
- package/server/routes/timeline.js +43 -0
- package/server/services/account/erasure.js +263 -0
- package/server/services/ai/hooks.js +4 -1
- package/server/services/ai/loop/agent_engine_core.js +8 -1
- package/server/services/ai/loop/blank_recovery.js +36 -0
- package/server/services/ai/loop/conversation_loop.js +166 -33
- package/server/services/ai/messagingFallback.js +22 -0
- package/server/services/ai/rate_limits.js +28 -5
- package/server/services/ai/systemPrompt.js +6 -5
- package/server/services/ai/taskAnalysis.js +2 -0
- package/server/services/ai/toolEvidence.js +15 -0
- package/server/services/ai/toolResult.js +40 -0
- package/server/services/ai/tools.js +163 -4
- package/server/services/android/controller.js +6 -2
- package/server/services/billing/plans.js +2 -1
- package/server/services/browser/anti_detection.js +192 -0
- package/server/services/browser/controller.js +180 -54
- package/server/services/desktop/auth.js +3 -0
- package/server/services/desktop/registry.js +50 -2
- package/server/services/integrations/google/calendar.js +22 -14
- package/server/services/manager.js +12 -42
- package/server/services/memory/ingestion_chunking.js +268 -0
- package/server/services/messaging/telnyx.js +9 -8
- package/server/services/recordings/manager.js +60 -27
- package/server/services/runtime/docker-vm-manager.js +157 -266
- package/server/services/runtime/guest_bootstrap.js +17 -5
- package/server/services/runtime/guest_image.js +188 -0
- package/server/services/runtime/manager.js +0 -1
- package/server/services/runtime/validation.js +3 -8
- package/server/services/social_video/service.js +60 -10
- package/server/services/tasks/runtime.js +234 -9
- package/server/services/tasks/task_repository.js +13 -0
- package/server/services/timeline/service.js +558 -0
- package/server/services/wearable/gateway.js +1 -1
- package/server/services/websocket.js +21 -3
- package/server/services/desktop/screenRecorder.js +0 -292
- 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) => {
|