neoagent 3.0.1-beta.8 → 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 (100) 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.css +78 -0
  42. package/server/admin/admin.js +436 -10
  43. package/server/admin/billing.js +165 -42
  44. package/server/admin/index.html +80 -5
  45. package/server/admin/users.js +15 -15
  46. package/server/db/database.js +125 -20
  47. package/server/http/routes.js +1 -0
  48. package/server/http/static.js +4 -2
  49. package/server/public/.last_build_id +1 -1
  50. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  51. package/server/public/canvaskit/wimp.js.symbols +8475 -8467
  52. package/server/public/canvaskit/wimp.wasm +0 -0
  53. package/server/public/flutter_bootstrap.js +2 -2
  54. package/server/public/main.dart.js +91077 -87037
  55. package/server/routes/account.js +53 -0
  56. package/server/routes/admin.js +345 -64
  57. package/server/routes/agents.js +203 -21
  58. package/server/routes/billing.js +5 -0
  59. package/server/routes/browser.js +8 -1
  60. package/server/routes/recordings.js +96 -6
  61. package/server/routes/screenHistory.js +140 -2
  62. package/server/routes/timeline.js +43 -0
  63. package/server/services/account/erasure.js +263 -0
  64. package/server/services/ai/hooks.js +4 -1
  65. package/server/services/ai/loop/agent_engine_core.js +8 -1
  66. package/server/services/ai/loop/blank_recovery.js +36 -0
  67. package/server/services/ai/loop/conversation_loop.js +166 -33
  68. package/server/services/ai/messagingFallback.js +22 -0
  69. package/server/services/ai/rate_limits.js +28 -5
  70. package/server/services/ai/systemPrompt.js +6 -5
  71. package/server/services/ai/taskAnalysis.js +2 -0
  72. package/server/services/ai/toolEvidence.js +15 -0
  73. package/server/services/ai/toolResult.js +40 -0
  74. package/server/services/ai/tools.js +163 -4
  75. package/server/services/android/controller.js +6 -2
  76. package/server/services/billing/plans.js +2 -1
  77. package/server/services/browser/anti_detection.js +192 -0
  78. package/server/services/browser/controller.js +180 -54
  79. package/server/services/desktop/auth.js +3 -0
  80. package/server/services/desktop/registry.js +50 -2
  81. package/server/services/integrations/google/calendar.js +22 -14
  82. package/server/services/manager.js +12 -42
  83. package/server/services/memory/ingestion_chunking.js +268 -0
  84. package/server/services/messaging/telnyx.js +9 -8
  85. package/server/services/recordings/manager.js +60 -27
  86. package/server/services/runtime/backends/local-vm.js +40 -22
  87. package/server/services/runtime/docker-vm-manager.js +157 -266
  88. package/server/services/runtime/guest_bootstrap.js +17 -5
  89. package/server/services/runtime/guest_image.js +188 -0
  90. package/server/services/runtime/manager.js +0 -1
  91. package/server/services/runtime/validation.js +3 -8
  92. package/server/services/social_video/service.js +60 -10
  93. package/server/services/tasks/runtime.js +234 -9
  94. package/server/services/tasks/schedule_utils.js +5 -5
  95. package/server/services/tasks/task_repository.js +13 -0
  96. package/server/services/timeline/service.js +558 -0
  97. package/server/services/wearable/gateway.js +1 -1
  98. package/server/services/websocket.js +21 -3
  99. package/server/services/desktop/screenRecorder.js +0 -292
  100. package/server/services/desktop/screen_recorder_support.js +0 -46
@@ -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 = Math.min(parseInt(req.query.limit) || 100, 500);
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
- FROM conversation_history WHERE user_id = ? AND agent_id = ? ORDER BY created_at DESC LIMIT ?
42
- `).all(userId, agentId, limit);
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
- FROM messages WHERE user_id = ? AND agent_id = ? AND platform != 'web'
56
- ORDER BY created_at DESC LIMIT ?
57
- `).all(userId, agentId, limit);
58
-
59
- // Normalize SQL datetime ('YYYY-MM-DD HH:MM:SS', treated as local) and ISO-Z strings to ms
60
- const toMs = (s) => {
61
- if (!s) return 0;
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 all = [...webMsgs, ...socialMsgs]
67
- .sort((a, b) => toMs(a.created_at) - toMs(b.created_at))
68
- .slice(-limit);
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({ messages: all, agentId });
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
@@ -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 });
@@ -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, { waitUntil: waitFor || 'domcontentloaded' });
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
- res.setHeader('Content-Type', mimeType);
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
- continue;
205
+ // Skip chunk files that are missing or unreadable on disk.
152
206
  }
153
- const stream = fs.createReadStream(filePath);
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 s.id, s.timestamp, s.app_name, s.text_content
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 id, timestamp, app_name, text_content
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;