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
package/server/routes/agents.js
CHANGED
|
@@ -10,6 +10,154 @@ const { buildAgentRunContext } = require('./_helpers/agentRunContext');
|
|
|
10
10
|
|
|
11
11
|
router.use(requireAuth);
|
|
12
12
|
|
|
13
|
+
const CHAT_HISTORY_DEFAULT_LIMIT = 40;
|
|
14
|
+
const CHAT_HISTORY_MAX_LIMIT = 100;
|
|
15
|
+
|
|
16
|
+
function normalizedTimestampExpression(valueSql) {
|
|
17
|
+
return `julianday(CASE WHEN instr(${valueSql}, 'T') > 0 THEN ${valueSql} ELSE replace(${valueSql}, ' ', 'T') || 'Z' END)`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function normalizeChatHistoryLimit(rawLimit) {
|
|
21
|
+
const parsed = Number.parseInt(rawLimit, 10);
|
|
22
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
23
|
+
return CHAT_HISTORY_DEFAULT_LIMIT;
|
|
24
|
+
}
|
|
25
|
+
return Math.min(parsed, CHAT_HISTORY_MAX_LIMIT);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseChatHistoryCursor(query) {
|
|
29
|
+
const createdAt = query.beforeCreatedAt?.toString().trim() || '';
|
|
30
|
+
const source = query.beforeSource?.toString().trim() || '';
|
|
31
|
+
const id = query.beforeId?.toString().trim() || '';
|
|
32
|
+
if (!createdAt || !source || !id) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return { createdAt, source, id };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function chatHistoryTimestampMs(value) {
|
|
39
|
+
if (!value) return 0;
|
|
40
|
+
const normalized = value.includes('T') ? value : `${value.replace(' ', 'T')}Z`;
|
|
41
|
+
const parsed = new Date(normalized).getTime();
|
|
42
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function compareChatHistoryDesc(left, right) {
|
|
46
|
+
const timestampDiff =
|
|
47
|
+
chatHistoryTimestampMs(right.created_at) - chatHistoryTimestampMs(left.created_at);
|
|
48
|
+
if (timestampDiff !== 0) {
|
|
49
|
+
return timestampDiff;
|
|
50
|
+
}
|
|
51
|
+
const sourceDiff = String(right.history_source || '').localeCompare(
|
|
52
|
+
String(left.history_source || '')
|
|
53
|
+
);
|
|
54
|
+
if (sourceDiff !== 0) {
|
|
55
|
+
return sourceDiff;
|
|
56
|
+
}
|
|
57
|
+
return String(right.id || '').localeCompare(String(left.id || ''));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function buildRunUsageSummary(runId) {
|
|
61
|
+
const rows = db.prepare(`
|
|
62
|
+
SELECT
|
|
63
|
+
provider,
|
|
64
|
+
model,
|
|
65
|
+
phase,
|
|
66
|
+
input_tokens,
|
|
67
|
+
output_tokens,
|
|
68
|
+
reasoning_tokens,
|
|
69
|
+
cached_read_tokens,
|
|
70
|
+
cache_write_tokens,
|
|
71
|
+
total_tokens,
|
|
72
|
+
estimated_cost_usd,
|
|
73
|
+
latency_ms
|
|
74
|
+
FROM agent_model_usage
|
|
75
|
+
WHERE run_id = ?
|
|
76
|
+
ORDER BY id ASC
|
|
77
|
+
`).all(runId);
|
|
78
|
+
const totals = {
|
|
79
|
+
inputTokens: 0,
|
|
80
|
+
outputTokens: 0,
|
|
81
|
+
reasoningTokens: 0,
|
|
82
|
+
cachedReadTokens: 0,
|
|
83
|
+
cacheWriteTokens: 0,
|
|
84
|
+
totalTokens: 0,
|
|
85
|
+
estimatedCostUsd: 0,
|
|
86
|
+
pricedCallCount: 0,
|
|
87
|
+
latencyMs: 0,
|
|
88
|
+
};
|
|
89
|
+
const models = new Map();
|
|
90
|
+
|
|
91
|
+
for (const row of rows) {
|
|
92
|
+
const inputTokens = Number(row.input_tokens || 0);
|
|
93
|
+
const outputTokens = Number(row.output_tokens || 0);
|
|
94
|
+
const reasoningTokens = Number(row.reasoning_tokens || 0);
|
|
95
|
+
const cachedReadTokens = Number(row.cached_read_tokens || 0);
|
|
96
|
+
const cacheWriteTokens = Number(row.cache_write_tokens || 0);
|
|
97
|
+
const totalTokens = Number(row.total_tokens || 0);
|
|
98
|
+
const latencyMs = Number(row.latency_ms || 0);
|
|
99
|
+
const estimatedCostUsd = Number(row.estimated_cost_usd);
|
|
100
|
+
const key = `${row.provider}:${row.model}`;
|
|
101
|
+
|
|
102
|
+
totals.inputTokens += inputTokens;
|
|
103
|
+
totals.outputTokens += outputTokens;
|
|
104
|
+
totals.reasoningTokens += reasoningTokens;
|
|
105
|
+
totals.cachedReadTokens += cachedReadTokens;
|
|
106
|
+
totals.cacheWriteTokens += cacheWriteTokens;
|
|
107
|
+
totals.totalTokens += totalTokens;
|
|
108
|
+
totals.latencyMs += latencyMs;
|
|
109
|
+
if (Number.isFinite(estimatedCostUsd)) {
|
|
110
|
+
totals.estimatedCostUsd += estimatedCostUsd;
|
|
111
|
+
totals.pricedCallCount += 1;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!models.has(key)) {
|
|
115
|
+
models.set(key, {
|
|
116
|
+
provider: row.provider,
|
|
117
|
+
model: row.model,
|
|
118
|
+
callCount: 0,
|
|
119
|
+
inputTokens: 0,
|
|
120
|
+
outputTokens: 0,
|
|
121
|
+
reasoningTokens: 0,
|
|
122
|
+
cachedReadTokens: 0,
|
|
123
|
+
cacheWriteTokens: 0,
|
|
124
|
+
totalTokens: 0,
|
|
125
|
+
estimatedCostUsd: 0,
|
|
126
|
+
pricedCallCount: 0,
|
|
127
|
+
latencyMs: 0,
|
|
128
|
+
phases: new Set(),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const aggregate = models.get(key);
|
|
133
|
+
aggregate.callCount += 1;
|
|
134
|
+
aggregate.inputTokens += inputTokens;
|
|
135
|
+
aggregate.outputTokens += outputTokens;
|
|
136
|
+
aggregate.reasoningTokens += reasoningTokens;
|
|
137
|
+
aggregate.cachedReadTokens += cachedReadTokens;
|
|
138
|
+
aggregate.cacheWriteTokens += cacheWriteTokens;
|
|
139
|
+
aggregate.totalTokens += totalTokens;
|
|
140
|
+
aggregate.latencyMs += latencyMs;
|
|
141
|
+
aggregate.phases.add(String(row.phase || '').trim() || 'model_turn');
|
|
142
|
+
if (Number.isFinite(estimatedCostUsd)) {
|
|
143
|
+
aggregate.estimatedCostUsd += estimatedCostUsd;
|
|
144
|
+
aggregate.pricedCallCount += 1;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
totals: {
|
|
150
|
+
...totals,
|
|
151
|
+
estimatedCostUsd: totals.pricedCallCount > 0 ? totals.estimatedCostUsd : null,
|
|
152
|
+
},
|
|
153
|
+
models: [...models.values()].map((entry) => ({
|
|
154
|
+
...entry,
|
|
155
|
+
estimatedCostUsd: entry.pricedCallCount > 0 ? entry.estimatedCostUsd : null,
|
|
156
|
+
phases: [...entry.phases],
|
|
157
|
+
})),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
13
161
|
// List agent runs
|
|
14
162
|
router.get('/', (req, res) => {
|
|
15
163
|
const limit = Math.min(parseInt(req.query.limit) || 50, 200);
|
|
@@ -23,9 +171,31 @@ router.get('/', (req, res) => {
|
|
|
23
171
|
|
|
24
172
|
// Chat history (web + social messages merged)
|
|
25
173
|
router.get('/chat-history', (req, res) => {
|
|
26
|
-
const limit =
|
|
174
|
+
const limit = normalizeChatHistoryLimit(req.query.limit);
|
|
175
|
+
const queryLimit = limit + 1;
|
|
27
176
|
const userId = req.session.userId;
|
|
28
177
|
const agentId = resolveAgentId(userId, getAgentIdFromRequest(req));
|
|
178
|
+
const cursor = parseChatHistoryCursor(req.query);
|
|
179
|
+
const cursorTimestampSql = normalizedTimestampExpression('@beforeCreatedAt');
|
|
180
|
+
const historyTimestampSql = normalizedTimestampExpression('created_at');
|
|
181
|
+
const cursorClause = (sourceName) =>
|
|
182
|
+
cursor
|
|
183
|
+
? `AND (
|
|
184
|
+
${historyTimestampSql} < ${cursorTimestampSql}
|
|
185
|
+
OR (${historyTimestampSql} = ${cursorTimestampSql} AND '${sourceName}' < @beforeSource)
|
|
186
|
+
OR (${historyTimestampSql} = ${cursorTimestampSql} AND '${sourceName}' = @beforeSource AND CAST(id AS TEXT) < @beforeId)
|
|
187
|
+
)`
|
|
188
|
+
: '';
|
|
189
|
+
const queryParams = cursor
|
|
190
|
+
? {
|
|
191
|
+
userId,
|
|
192
|
+
agentId,
|
|
193
|
+
limit: queryLimit,
|
|
194
|
+
beforeCreatedAt: cursor.createdAt,
|
|
195
|
+
beforeSource: cursor.source,
|
|
196
|
+
beforeId: cursor.id,
|
|
197
|
+
}
|
|
198
|
+
: { userId, agentId, limit: queryLimit };
|
|
29
199
|
|
|
30
200
|
const webMsgs = db.prepare(`
|
|
31
201
|
SELECT
|
|
@@ -37,9 +207,14 @@ router.get('/chat-history', (req, res) => {
|
|
|
37
207
|
created_at,
|
|
38
208
|
agent_run_id AS run_id,
|
|
39
209
|
metadata,
|
|
40
|
-
NULL AS tool_calls
|
|
41
|
-
|
|
42
|
-
|
|
210
|
+
NULL AS tool_calls,
|
|
211
|
+
'conversation' AS history_source
|
|
212
|
+
FROM conversation_history
|
|
213
|
+
WHERE user_id = @userId AND agent_id = @agentId
|
|
214
|
+
${cursorClause('conversation')}
|
|
215
|
+
ORDER BY ${historyTimestampSql} DESC, CAST(id AS TEXT) DESC
|
|
216
|
+
LIMIT @limit
|
|
217
|
+
`).all(queryParams);
|
|
43
218
|
|
|
44
219
|
const socialMsgs = db.prepare(`
|
|
45
220
|
SELECT
|
|
@@ -51,23 +226,27 @@ router.get('/chat-history', (req, res) => {
|
|
|
51
226
|
created_at,
|
|
52
227
|
run_id,
|
|
53
228
|
metadata,
|
|
54
|
-
tool_calls
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const normalized = s.includes('T') ? s : s.replace(' ', 'T') + 'Z';
|
|
63
|
-
return new Date(normalized).getTime();
|
|
64
|
-
};
|
|
229
|
+
tool_calls,
|
|
230
|
+
'message' AS history_source
|
|
231
|
+
FROM messages
|
|
232
|
+
WHERE user_id = @userId AND agent_id = @agentId AND platform != 'web'
|
|
233
|
+
${cursorClause('message')}
|
|
234
|
+
ORDER BY ${historyTimestampSql} DESC, CAST(id AS TEXT) DESC
|
|
235
|
+
LIMIT @limit
|
|
236
|
+
`).all(queryParams);
|
|
65
237
|
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
238
|
+
const merged = [...webMsgs, ...socialMsgs].sort(compareChatHistoryDesc);
|
|
239
|
+
const page = merged.slice(0, limit);
|
|
240
|
+
const oldest = page.length > 0 ? page[page.length - 1] : null;
|
|
69
241
|
|
|
70
|
-
res.json({
|
|
242
|
+
res.json({
|
|
243
|
+
messages: [...page].reverse(),
|
|
244
|
+
agentId,
|
|
245
|
+
hasMore: merged.length > limit,
|
|
246
|
+
nextBeforeCreatedAt: oldest?.created_at || null,
|
|
247
|
+
nextBeforeSource: oldest?.history_source || null,
|
|
248
|
+
nextBeforeId: oldest?.id?.toString() || null,
|
|
249
|
+
});
|
|
71
250
|
});
|
|
72
251
|
|
|
73
252
|
// Create new agent run
|
|
@@ -130,6 +309,7 @@ router.post('/', async (req, res) => {
|
|
|
130
309
|
|
|
131
310
|
res.json(result);
|
|
132
311
|
} catch (err) {
|
|
312
|
+
console.error('[Agents] Run failed:', err?.stack || err);
|
|
133
313
|
res.status(err?.statusCode || err?.status || 500).json({
|
|
134
314
|
error: sanitizeError(err),
|
|
135
315
|
code: err?.code,
|
|
@@ -145,8 +325,9 @@ router.get('/:id', (req, res) => {
|
|
|
145
325
|
|
|
146
326
|
const steps = db.prepare('SELECT * FROM agent_steps WHERE run_id = ? ORDER BY step_index ASC').all(run.id);
|
|
147
327
|
const history = db.prepare('SELECT * FROM conversation_history WHERE agent_run_id = ? ORDER BY created_at ASC').all(run.id);
|
|
328
|
+
const usage = buildRunUsageSummary(run.id);
|
|
148
329
|
|
|
149
|
-
res.json({ run, steps, history });
|
|
330
|
+
res.json({ run, steps, history, usage });
|
|
150
331
|
});
|
|
151
332
|
|
|
152
333
|
// Get detailed steps for a run (for activity history replay)
|
|
@@ -180,8 +361,9 @@ router.get('/:id/steps', (req, res) => {
|
|
|
180
361
|
|| latestHistoryAssistant?.content
|
|
181
362
|
|| run.final_response
|
|
182
363
|
|| null;
|
|
364
|
+
const usage = buildRunUsageSummary(run.id);
|
|
183
365
|
|
|
184
|
-
res.json({ run, steps, events: listRunEvents(run.id), response });
|
|
366
|
+
res.json({ run, steps, events: listRunEvents(run.id), response, usage });
|
|
185
367
|
});
|
|
186
368
|
|
|
187
369
|
// Abort a run
|
package/server/routes/billing.js
CHANGED
|
@@ -78,6 +78,11 @@ router.post('/trial', async (req, res) => {
|
|
|
78
78
|
try {
|
|
79
79
|
const { planId, deviceFingerprint } = req.body;
|
|
80
80
|
if (!planId) return res.status(400).json({ error: 'planId is required.' });
|
|
81
|
+
if (deviceFingerprint !== undefined) {
|
|
82
|
+
if (typeof deviceFingerprint !== 'string' || deviceFingerprint.length > 256 || !/^[a-zA-Z0-9_+/=.-]{1,256}$/.test(deviceFingerprint)) {
|
|
83
|
+
return res.status(400).json({ error: 'Invalid deviceFingerprint.' });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
81
86
|
const ip = req.ip || req.socket?.remoteAddress;
|
|
82
87
|
const result = await subs.startTrial(req.session.userId, planId, { ip, deviceFp: deviceFingerprint });
|
|
83
88
|
res.json({ subscription: result.subscription });
|
package/server/routes/browser.js
CHANGED
|
@@ -103,7 +103,14 @@ router.post('/navigate', async (req, res) => {
|
|
|
103
103
|
return res.status(403).json({ error: 'This URL is not permitted.' });
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
const result = await bc.navigate(url, {
|
|
106
|
+
const result = await bc.navigate(url, {
|
|
107
|
+
waitUntil: waitFor || req.body?.waitUntil || 'domcontentloaded',
|
|
108
|
+
waitFor,
|
|
109
|
+
screenshot: req.body?.screenshot !== false,
|
|
110
|
+
fullPage: req.body?.fullPage === true,
|
|
111
|
+
referrerMode: req.body?.referrerMode,
|
|
112
|
+
challengeRetry: req.body?.challengeRetry,
|
|
113
|
+
});
|
|
107
114
|
res.json(result);
|
|
108
115
|
} catch (err) {
|
|
109
116
|
res.status(500).json({ error: sanitizeError(err) });
|
|
@@ -43,6 +43,62 @@ function getChunkMetadata(req) {
|
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
const WAV_HEADER_BYTES = 44;
|
|
47
|
+
|
|
48
|
+
// Reads the canonical 44-byte WAV header our recorders emit. Returns the audio
|
|
49
|
+
// format fields, or null when the file is not a canonical WAV (in which case
|
|
50
|
+
// the caller streams the chunks unmodified instead of rebuilding a WAV).
|
|
51
|
+
async function readCanonicalWavHeader(filePath) {
|
|
52
|
+
let handle;
|
|
53
|
+
try {
|
|
54
|
+
handle = await fs.promises.open(filePath, 'r');
|
|
55
|
+
const { bytesRead, buffer } = await handle.read(
|
|
56
|
+
Buffer.alloc(WAV_HEADER_BYTES),
|
|
57
|
+
0,
|
|
58
|
+
WAV_HEADER_BYTES,
|
|
59
|
+
0,
|
|
60
|
+
);
|
|
61
|
+
if (
|
|
62
|
+
bytesRead < WAV_HEADER_BYTES
|
|
63
|
+
|| buffer.toString('ascii', 0, 4) !== 'RIFF'
|
|
64
|
+
|| buffer.toString('ascii', 8, 12) !== 'WAVE'
|
|
65
|
+
|| buffer.toString('ascii', 36, 40) !== 'data'
|
|
66
|
+
) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
audioFormat: buffer.readUInt16LE(20),
|
|
71
|
+
channelCount: buffer.readUInt16LE(22),
|
|
72
|
+
sampleRate: buffer.readUInt32LE(24),
|
|
73
|
+
bitsPerSample: buffer.readUInt16LE(34),
|
|
74
|
+
};
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
} finally {
|
|
78
|
+
await handle?.close();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function buildWavHeader({ audioFormat, channelCount, sampleRate, bitsPerSample }, dataLength) {
|
|
83
|
+
const blockAlign = channelCount * (bitsPerSample / 8);
|
|
84
|
+
const byteRate = sampleRate * blockAlign;
|
|
85
|
+
const header = Buffer.alloc(WAV_HEADER_BYTES);
|
|
86
|
+
header.write('RIFF', 0, 4, 'ascii');
|
|
87
|
+
header.writeUInt32LE(36 + dataLength, 4);
|
|
88
|
+
header.write('WAVE', 8, 4, 'ascii');
|
|
89
|
+
header.write('fmt ', 12, 4, 'ascii');
|
|
90
|
+
header.writeUInt32LE(16, 16);
|
|
91
|
+
header.writeUInt16LE(audioFormat, 20);
|
|
92
|
+
header.writeUInt16LE(channelCount, 22);
|
|
93
|
+
header.writeUInt32LE(sampleRate, 24);
|
|
94
|
+
header.writeUInt32LE(byteRate, 28);
|
|
95
|
+
header.writeUInt16LE(blockAlign, 32);
|
|
96
|
+
header.writeUInt16LE(bitsPerSample, 34);
|
|
97
|
+
header.write('data', 36, 4, 'ascii');
|
|
98
|
+
header.writeUInt32LE(dataLength, 40);
|
|
99
|
+
return header;
|
|
100
|
+
}
|
|
101
|
+
|
|
46
102
|
function buildInlineFilename(sourceKey) {
|
|
47
103
|
const original = `${String(sourceKey || 'recording').normalize('NFKC')}.audio`
|
|
48
104
|
.replace(/[\r\n]+/g, ' ')
|
|
@@ -136,10 +192,7 @@ router.get('/:sessionId/audio/:sourceKey', async (req, res) => {
|
|
|
136
192
|
});
|
|
137
193
|
}
|
|
138
194
|
|
|
139
|
-
|
|
140
|
-
res.setHeader('Cache-Control', 'no-store');
|
|
141
|
-
res.setHeader('Content-Disposition', buildInlineFilename(source.source_key));
|
|
142
|
-
|
|
195
|
+
const readableChunks = [];
|
|
143
196
|
for (const chunk of chunks) {
|
|
144
197
|
const filePath = chunk.file_path;
|
|
145
198
|
if (!filePath) {
|
|
@@ -147,10 +200,47 @@ router.get('/:sessionId/audio/:sourceKey', async (req, res) => {
|
|
|
147
200
|
}
|
|
148
201
|
try {
|
|
149
202
|
await fs.promises.access(filePath, fs.constants.R_OK);
|
|
203
|
+
readableChunks.push(chunk);
|
|
150
204
|
} catch {
|
|
151
|
-
|
|
205
|
+
// Skip chunk files that are missing or unreadable on disk.
|
|
152
206
|
}
|
|
153
|
-
|
|
207
|
+
}
|
|
208
|
+
if (readableChunks.length === 0) {
|
|
209
|
+
return res.status(404).json({ error: 'No audio chunks available.' });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
res.setHeader('Content-Type', mimeType);
|
|
213
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
214
|
+
res.setHeader('Content-Disposition', buildInlineFilename(source.source_key));
|
|
215
|
+
|
|
216
|
+
// Each WAV chunk is a self-contained file with its own 44-byte RIFF header
|
|
217
|
+
// whose declared length only covers that chunk. Streaming them back-to-back
|
|
218
|
+
// produces a file most players stop reading after the first chunk. Rebuild
|
|
219
|
+
// one continuous WAV: a single header sized for the combined PCM, followed
|
|
220
|
+
// by the raw sample data from every chunk. Non-WAV containers (e.g. WebM)
|
|
221
|
+
// concatenate into a valid stream as-is.
|
|
222
|
+
const wavFormat = /wav/i.test(mimeType)
|
|
223
|
+
? await readCanonicalWavHeader(readableChunks[0].file_path)
|
|
224
|
+
: null;
|
|
225
|
+
|
|
226
|
+
if (wavFormat) {
|
|
227
|
+
let totalPcmBytes = 0;
|
|
228
|
+
for (const chunk of readableChunks) {
|
|
229
|
+
const { size } = await fs.promises.stat(chunk.file_path);
|
|
230
|
+
totalPcmBytes += Math.max(0, size - WAV_HEADER_BYTES);
|
|
231
|
+
}
|
|
232
|
+
res.write(buildWavHeader(wavFormat, totalPcmBytes));
|
|
233
|
+
for (const chunk of readableChunks) {
|
|
234
|
+
const stream = fs.createReadStream(chunk.file_path, { start: WAV_HEADER_BYTES });
|
|
235
|
+
stream.pipe(res, { end: false });
|
|
236
|
+
await finished(stream);
|
|
237
|
+
}
|
|
238
|
+
res.end();
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
for (const chunk of readableChunks) {
|
|
243
|
+
const stream = fs.createReadStream(chunk.file_path);
|
|
154
244
|
stream.pipe(res, { end: false });
|
|
155
245
|
await finished(stream);
|
|
156
246
|
}
|
|
@@ -10,6 +10,32 @@ const router = express.Router();
|
|
|
10
10
|
|
|
11
11
|
router.use(requireAuth);
|
|
12
12
|
|
|
13
|
+
function getTimelineService(req) {
|
|
14
|
+
return req.app?.locals?.timelineService || null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function normalizeEntries(rawEntries) {
|
|
18
|
+
if (!Array.isArray(rawEntries)) {
|
|
19
|
+
throw new Error('entries must be an array.');
|
|
20
|
+
}
|
|
21
|
+
return rawEntries.map((entry) => {
|
|
22
|
+
if (!entry || typeof entry !== 'object') {
|
|
23
|
+
throw new Error('Each entry must be an object.');
|
|
24
|
+
}
|
|
25
|
+
const text = String(entry.text || '').replace(/\s+/g, ' ').trim();
|
|
26
|
+
if (!text) {
|
|
27
|
+
throw new Error('Entry text is required.');
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
capturedAt: entry.capturedAt,
|
|
31
|
+
frontmostApp: entry.frontmostApp,
|
|
32
|
+
windowTitle: entry.windowTitle,
|
|
33
|
+
text,
|
|
34
|
+
ocrConfidence: entry.ocrConfidence,
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
13
39
|
router.get('/search', (req, res) => {
|
|
14
40
|
const { q, limit = 50, offset = 0 } = req.query;
|
|
15
41
|
const userId = req.session.userId;
|
|
@@ -21,7 +47,24 @@ router.get('/search', (req, res) => {
|
|
|
21
47
|
// Full text search. buildFtsQuery sanitizes user input so FTS5 operator
|
|
22
48
|
// characters (hyphens, AND/OR/NOT) don't throw and 500 the request.
|
|
23
49
|
results = db.prepare(`
|
|
24
|
-
SELECT
|
|
50
|
+
SELECT
|
|
51
|
+
s.id,
|
|
52
|
+
s.timestamp,
|
|
53
|
+
s.captured_at,
|
|
54
|
+
s.captured_at AS capturedAt,
|
|
55
|
+
s.device_id,
|
|
56
|
+
s.device_id AS deviceId,
|
|
57
|
+
s.device_label,
|
|
58
|
+
s.device_label AS deviceLabel,
|
|
59
|
+
s.app_name,
|
|
60
|
+
s.app_name AS appName,
|
|
61
|
+
s.window_title,
|
|
62
|
+
s.window_title AS windowTitle,
|
|
63
|
+
s.text_content,
|
|
64
|
+
s.ocr_engine,
|
|
65
|
+
s.ocr_engine AS ocrEngine,
|
|
66
|
+
s.ocr_confidence,
|
|
67
|
+
s.ocr_confidence AS ocrConfidence
|
|
25
68
|
FROM screen_history_fts fts
|
|
26
69
|
JOIN screen_history s ON fts.rowid = s.id
|
|
27
70
|
WHERE screen_history_fts MATCH ? AND s.user_id = ?
|
|
@@ -34,7 +77,24 @@ router.get('/search', (req, res) => {
|
|
|
34
77
|
} else {
|
|
35
78
|
// Recent history
|
|
36
79
|
results = db.prepare(`
|
|
37
|
-
SELECT
|
|
80
|
+
SELECT
|
|
81
|
+
id,
|
|
82
|
+
timestamp,
|
|
83
|
+
captured_at,
|
|
84
|
+
captured_at AS capturedAt,
|
|
85
|
+
device_id,
|
|
86
|
+
device_id AS deviceId,
|
|
87
|
+
device_label,
|
|
88
|
+
device_label AS deviceLabel,
|
|
89
|
+
app_name,
|
|
90
|
+
app_name AS appName,
|
|
91
|
+
window_title,
|
|
92
|
+
window_title AS windowTitle,
|
|
93
|
+
text_content,
|
|
94
|
+
ocr_engine,
|
|
95
|
+
ocr_engine AS ocrEngine,
|
|
96
|
+
ocr_confidence,
|
|
97
|
+
ocr_confidence AS ocrConfidence
|
|
38
98
|
FROM screen_history
|
|
39
99
|
WHERE user_id = ?
|
|
40
100
|
ORDER BY timestamp DESC
|
|
@@ -49,4 +109,82 @@ router.get('/search', (req, res) => {
|
|
|
49
109
|
}
|
|
50
110
|
});
|
|
51
111
|
|
|
112
|
+
router.post('/entries', express.json(), (req, res) => {
|
|
113
|
+
try {
|
|
114
|
+
const userId = req.session.userId;
|
|
115
|
+
const deviceId = String(req.body?.deviceId || '').trim();
|
|
116
|
+
const activationId = String(req.body?.activationId || '').trim();
|
|
117
|
+
if (!deviceId) {
|
|
118
|
+
return res.status(400).json({ error: 'deviceId is required.' });
|
|
119
|
+
}
|
|
120
|
+
if (!activationId) {
|
|
121
|
+
return res.status(400).json({ error: 'activationId is required.' });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const device = db.prepare(
|
|
125
|
+
`SELECT device_id, label, activation_id, revoked_at, passive_history_enabled
|
|
126
|
+
FROM desktop_companion_devices
|
|
127
|
+
WHERE user_id = ? AND device_id = ?
|
|
128
|
+
LIMIT 1`
|
|
129
|
+
).get(userId, deviceId);
|
|
130
|
+
if (!device) {
|
|
131
|
+
return res.status(404).json({ error: 'Desktop companion device not found.' });
|
|
132
|
+
}
|
|
133
|
+
if (device.revoked_at) {
|
|
134
|
+
return res.status(403).json({ error: 'Desktop companion device has been revoked.' });
|
|
135
|
+
}
|
|
136
|
+
if (String(device.activation_id || '') !== activationId) {
|
|
137
|
+
return res.status(403).json({ error: 'activationId does not match the registered desktop companion device.' });
|
|
138
|
+
}
|
|
139
|
+
if (device.passive_history_enabled !== 1) {
|
|
140
|
+
return res.status(403).json({ error: 'Passive screen history is disabled for this desktop companion device.' });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const entries = normalizeEntries(req.body?.entries);
|
|
144
|
+
const timelineService = getTimelineService(req);
|
|
145
|
+
if (!timelineService || typeof timelineService.storeScreenEntries !== 'function') {
|
|
146
|
+
return res.status(503).json({ error: 'Timeline service is unavailable.' });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const result = timelineService.storeScreenEntries({
|
|
150
|
+
userId,
|
|
151
|
+
deviceId,
|
|
152
|
+
deviceLabel: device.label || deviceId,
|
|
153
|
+
entries,
|
|
154
|
+
});
|
|
155
|
+
req.app?.locals?.desktopCompanionRegistry?.updatePassiveHistoryState?.(
|
|
156
|
+
userId,
|
|
157
|
+
deviceId,
|
|
158
|
+
{
|
|
159
|
+
enabled: true,
|
|
160
|
+
lastUploadedAt: new Date().toISOString(),
|
|
161
|
+
lastError: null,
|
|
162
|
+
},
|
|
163
|
+
);
|
|
164
|
+
res.status(201).json({
|
|
165
|
+
ok: true,
|
|
166
|
+
insertedCount: result.insertedCount,
|
|
167
|
+
});
|
|
168
|
+
} catch (err) {
|
|
169
|
+
const message = getErrorMessage(err);
|
|
170
|
+
const userId = req.session?.userId;
|
|
171
|
+
const deviceId = String(req.body?.deviceId || '').trim();
|
|
172
|
+
if (userId && deviceId) {
|
|
173
|
+
try {
|
|
174
|
+
req.app?.locals?.desktopCompanionRegistry?.updatePassiveHistoryState?.(
|
|
175
|
+
userId,
|
|
176
|
+
deviceId,
|
|
177
|
+
{
|
|
178
|
+
enabled: true,
|
|
179
|
+
lastError: message,
|
|
180
|
+
},
|
|
181
|
+
);
|
|
182
|
+
} catch {
|
|
183
|
+
// Best-effort; do not mask the original ingest failure.
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
res.status(400).json({ error: message });
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
52
190
|
module.exports = router;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const express = require('express');
|
|
4
|
+
const { requireAuth } = require('../middleware/auth');
|
|
5
|
+
|
|
6
|
+
const router = express.Router();
|
|
7
|
+
|
|
8
|
+
router.use(requireAuth);
|
|
9
|
+
|
|
10
|
+
router.get('/', (req, res) => {
|
|
11
|
+
const timelineService = req.app?.locals?.timelineService;
|
|
12
|
+
if (!timelineService || typeof timelineService.listEvents !== 'function') {
|
|
13
|
+
return res.status(503).json({ error: 'Timeline service is unavailable.' });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const rawSources = []
|
|
17
|
+
.concat(req.query.source || [])
|
|
18
|
+
.concat(req.query.sources || []);
|
|
19
|
+
const sources = rawSources
|
|
20
|
+
.flatMap((value) => String(value || '').split(','))
|
|
21
|
+
.map((value) => value.trim().toLowerCase())
|
|
22
|
+
.filter(Boolean);
|
|
23
|
+
const items = timelineService.listEvents(req.session.userId, {
|
|
24
|
+
limit: req.query.limit,
|
|
25
|
+
beforeOccurredAt: req.query.beforeOccurredAt,
|
|
26
|
+
beforeId: req.query.beforeId,
|
|
27
|
+
agentId: req.query.agentId,
|
|
28
|
+
sources,
|
|
29
|
+
});
|
|
30
|
+
const lastItem = items[items.length - 1] || null;
|
|
31
|
+
|
|
32
|
+
res.json({
|
|
33
|
+
items,
|
|
34
|
+
nextCursor: lastItem
|
|
35
|
+
? {
|
|
36
|
+
beforeOccurredAt: lastItem.occurredAt,
|
|
37
|
+
beforeId: lastItem.id,
|
|
38
|
+
}
|
|
39
|
+
: null,
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
module.exports = router;
|