forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathagent.mjs
More file actions
1808 lines (1615 loc) · 70.5 KB
/
Copy pathagent.mjs
File metadata and controls
1808 lines (1615 loc) · 70.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env node
// Controllable seam: the hermetic WS e2e substitutes a scripted SDK module
// (tests/fixtures/fake-agent-sdk.mjs) so production stream handling is
// exercised without a live model.
const { query, tool, createSdkMcpServer } = await import(
process.env.OMI_AGENT_SDK_MODULE || "@anthropic-ai/claude-agent-sdk"
);
import { ALLOWED_TOOLS, buildAgentDefinitions } from "./query-config.mjs";
import { USER_MESSAGES, classifyError, isExpectedAbort, logEvent, markOwnedAbort, withRetry } from "./errors.mjs";
import { createSubagentRouter } from "./stream-routing.mjs";
import { buildCondensedSeed, planCondensation } from "./conversation-condenser.mjs";
import {
activityCounts,
runSqlBatch,
executeReadOnlyQuery,
truncateToolResult,
appUsageMatrix,
emptyRangeStatement,
formatAppUsage,
formatHourlyTimeline,
formatTopWindows,
hourlyTimeline,
resolveRange,
topWindows,
} from "./data-tools.mjs";
import Database from "better-sqlite3";
import { z } from "zod";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { createServer } from "http";
import { WebSocketServer } from "ws";
import { existsSync, mkdirSync, createWriteStream, statSync, renameSync, unlinkSync, writeFileSync } from "fs";
import { createInflateRaw, createGunzip } from "zlib";
import { homedir } from "os";
// --- Global error handlers: prevent SDK abort errors from crashing the process ---
// Owned-abort policy: only aborts within the grace window after OUR OWN
// interrupt()/abort() calls are expected; any other abort-shaped error is an
// unknown error and must surface at full detail (previously every message
// containing "aborted" was silently swallowed forever).
process.on('unhandledRejection', (err) => {
if (isExpectedAbort(err)) {
logEvent('debug', 'abort_suppressed', { error: err });
return;
}
logEvent('error', 'unhandled_rejection', { category: classifyError(err).category, error: err });
});
process.on('uncaughtException', (err) => {
if (isExpectedAbort(err)) {
logEvent('debug', 'abort_suppressed', { error: err });
return;
}
// Maximum-detail post-mortem, synchronously flushed — console.log can lose
// the line across process.exit.
const record = {
ts: new Date().toISOString(), level: 'error', event: 'uncaught_exception',
category: classifyError(err).category,
error: { name: err?.name, message: err?.message, stack: err?.stack },
memory: process.memoryUsage(), uptime_s: Math.round(process.uptime()),
};
try { writeFileSync(1, JSON.stringify(record) + "\n"); } catch { console.error(record); }
process.exit(1); // systemd restarts us; its restart backoff is the loop guard
});
// --- Async message queue for persistent session streaming input ---
class AsyncMessageQueue {
constructor() {
this._queue = [];
this._resolve = null;
this._done = false;
}
push(msg) {
if (this._resolve) {
this._resolve({ value: msg, done: false });
this._resolve = null;
} else {
this._queue.push(msg);
}
}
end() {
this._done = true;
if (this._resolve) {
this._resolve({ value: undefined, done: true });
this._resolve = null;
}
}
[Symbol.asyncIterator]() { return this; }
next() {
if (this._queue.length > 0)
return Promise.resolve({ value: this._queue.shift(), done: false });
if (this._done)
return Promise.resolve({ value: undefined, done: true });
return new Promise(r => { this._resolve = r; });
}
}
// --- Configuration ---
const DB_PATH = process.env.DB_PATH || join(homedir(), "omi-agent/data/omi.db");
const GCS_BASE = "https://storage.googleapis.com/based-hardware-agent";
const GEMINI_API_KEY = process.env.GEMINI_API_KEY;
const AUTH_TOKEN = process.env.AUTH_TOKEN;
const BACKEND_URL = process.env.BACKEND_URL || "https://api.omi.me";
const PORT = parseInt(process.env.PORT || "8080", 10);
const EMBEDDING_DIM = 3072;
// Max upload size: 10GB
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024 * 1024;
// --- Idle auto-stop ---
const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
const IDLE_CHECK_INTERVAL_MS = 5 * 60 * 1000; // check every 5 minutes
let lastActivityAt = Date.now();
// Tables allowed for incremental sync from desktop
const SYNC_TABLES = new Set([
"screenshots", "action_items", "transcription_sessions",
"transcription_segments", "memories", "staged_tasks",
"focus_sessions", "observations", "live_notes",
"ai_user_profiles", "task_dedup_log",
]);
const __dirname = dirname(fileURLToPath(import.meta.url));
const playwrightCli = join(__dirname, "node_modules", "@playwright", "mcp", "cli.js");
// --- Firebase token (passed from desktop app for backend API calls) ---
let userFirebaseToken = null;
let backendTools = [];
// --- Database Setup (lazy — opened on first use or after upload) ---
let db = null;
let defaultSystemPrompt = null;
let agentDefinitions = null;
let omiServer = null;
function openDatabase() {
if (db) {
try { db.close(); } catch {}
db = null;
}
if (!existsSync(DB_PATH)) {
return false;
}
db = Database(DB_PATH); // writable for /sync inserts; agent tool still blocks non-SELECT
db.pragma("journal_mode = WAL");
// No extra indexes needed: E6 scale bench (600K rows, 2026-07-22) showed the
// covering idx_screenshots_timestamp serves every range-comparison query the
// tools issue (worst tool call 126ms p50). The old date(timestamp,'localtime')
// index attempt failed on every open (non-deterministic date()) and date()-
// wrapped WHEREs full-scan anyway — range comparisons are the supported shape.
// Rebuild schema + system prompt + MCP server
const schema = getSchema();
defaultSystemPrompt = `You are an AI assistant with access to the user's OMI desktop database and their connected services.
This database contains their screen history (screenshots with OCR text), tasks, transcriptions, memories, and focus sessions.
DATABASE SCHEMA:
${schema}
TOOLS:
- **execute_sql**: Run SQL queries on the database. SELECT auto-limits to 200 rows. Supports FTS5 MATCH for keyword search. Use for structured queries (app usage, time ranges, task management, aggregations).
- **semantic_search**: Vector similarity search on screenshot OCR text. Use for fuzzy/conceptual queries where exact keywords won't work.
- **get_daily_recap**: Pre-formatted activity recap (apps, conversations, tasks, hourly timeline) for any date or range (start_date/end_date or days_ago). Use for "what did I do <day/range>" — single tool call, much faster than multiple SQL queries. If it reports no activity, that is authoritative — do not re-verify with SQL.
- **get_app_usage**: Day-by-app screen activity matrix for any range in one call. Use for "what did I spend time on", day comparisons, and usage patterns instead of per-day GROUP BY queries.
- **Playwright browser tools**: You can navigate websites, click elements, fill forms, take screenshots, etc. Use when the user asks you to do something on the web.
- **Backend tools** (calendar, gmail, health, conversations, memories, action items, web search, etc.): Use these when the user asks about their calendar events, emails, health data, past conversations, or wants to search the web. These tools connect to the user's real accounts.
GUIDELINES:
- For "what did I do today/yesterday/this week" queries, use get_daily_recap — it's a single tool call that returns a formatted summary. Much faster than multiple execute_sql calls.
- Only use get_conversations_tool when the user asks about specific conversation transcripts or content details. For activity summaries, get_daily_recap is faster.
- For time-filtered queries on screenshots, prefer range comparisons: WHERE timestamp >= datetime('now', 'start of day', '-1 day', 'localtime') AND timestamp < datetime('now', 'start of day', 'localtime'). Avoid wrapping the column in date() or strftime() in WHERE clauses — it's slower on large tables.
- Key tables: screenshots (timestamp, appName, windowTitle, ocrText — 600K+ rows, always filter by timestamp), action_items (description, completed, priority, category, dueAt), memories (content, category, source), transcription_sessions (title, overview, startedAt, finishedAt), transcription_segments (sessionId, speaker, text), focus_sessions (status, appOrSite, durationSeconds), observations (appName, contextSummary, currentActivity), goals (title, goalType, targetValue, currentValue), staged_tasks (description, priority), indexed_files (path, filename, fileType, folder), live_notes (sessionId, text), ai_user_profiles (profileText, generatedAt)
- FTS tables for keyword search: screenshots_fts(ocrText, windowTitle, appName), action_items_fts(description), staged_tasks_fts(description), task_chat_messages_fts(messageText)
- For task queries, use action_items table (or FTS: action_items_fts MATCH 'keyword')
- Task extraction creates near-duplicate entries: for "how many tasks" report COUNT(DISTINCT description), and GROUP BY description when listing so duplicates collapse
- For conversation queries, use transcription_sessions + transcription_segments
- For personal facts/preferences, query the memories table first
- For calendar, email, health data — use the backend tools (get_calendar_events_tool, get_gmail_messages_tool, etc.)
- For ROW-HEAVY work — searching through many screenshots/tasks/transcripts (content recall, task triage, multi-day pattern analysis) — delegate to the researcher subagent via the Task tool; it reads the rows in its own context and returns distilled findings. Answer directly when one or two aggregate queries (or a single get_daily_recap call) suffice.
- CONNECTING INTEGRATIONS: if a connector/tool reports it is "not connected", or the user asks how to connect calendar/gmail/etc., call get_connect_link with that capability (e.g. "gmail") and give the user the exact link it returns, verbatim. NEVER invent setup steps, mention external MCP servers/skills, or describe manual settings flows — only use get_connect_link.
- Be concise and helpful. Format results clearly.`;
agentDefinitions = buildAgentDefinitions(schema);
rebuildMcpServer();
console.log(`[db] Database opened: ${DB_PATH}`);
return true;
}
function isDatabaseReady() {
return db !== null;
}
function getSchema() {
const tables = db
.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'grdb_%' ORDER BY name"
)
.all();
let schema = "";
for (const { name } of tables) {
const cols = db.prepare(`PRAGMA table_info('${name}')`).all();
const colDefs = cols.map((c) => ` ${c.name} ${c.type}`).join("\n");
schema += `\n${name}:\n${colDefs}\n`;
const count = db.prepare(`SELECT COUNT(*) as n FROM "${name}"`).get();
schema += ` (${count.n} rows)\n`;
}
return schema;
}
// --- SQL Execution Logic ---
const BLOCKED_KEYWORDS = ["DROP", "ALTER", "CREATE", "PRAGMA", "ATTACH", "DETACH", "VACUUM"];
function executeSqlQuery(sqlQuery) {
if (!db) return JSON.stringify({ error: "Database not loaded. Upload omi.db first." });
// stmt.readonly-based guard (data-tools): allows CTE reads the old prefix
// guard rejected, caps rows by iteration, no keyword false-positives.
return executeReadOnlyQuery(db, sqlQuery);
}
// --- Semantic Search Logic ---
async function embedQueryText(text) {
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:embedContent?key=${GEMINI_API_KEY}`;
const body = {
model: "models/gemini-embedding-001",
content: { parts: [{ text }] },
taskType: "RETRIEVAL_QUERY",
};
const resp = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const json = await resp.json();
if (!json.embedding?.values) {
throw new Error(`Embedding API error: ${JSON.stringify(json.error || json)}`);
}
const raw = json.embedding.values.map(Number);
let norm = 0;
for (const v of raw) norm += v * v;
norm = Math.sqrt(norm);
return norm > 0 ? raw.map((v) => v / norm) : raw;
}
function readEmbeddingFromBlob(buffer) {
if (buffer.byteLength !== EMBEDDING_DIM * 4) return null;
return new Float32Array(buffer.buffer, buffer.byteOffset, EMBEDDING_DIM);
}
async function performSemanticSearch(searchQuery, days = 7, appFilter = null) {
if (!db) return JSON.stringify({ error: "Database not loaded. Upload omi.db first." });
if (!GEMINI_API_KEY) {
return JSON.stringify({ error: "GEMINI_API_KEY not set" });
}
const queryEmbedding = await embedQueryText(searchQuery);
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
const startStr = startDate.toISOString().replace("T", " ").slice(0, 19);
let sql = `SELECT id, timestamp, appName, windowTitle, substr(ocrText, 1, 300) as ocrPreview, embedding
FROM screenshots WHERE embedding IS NOT NULL AND timestamp >= ?`;
const params = [startStr];
if (appFilter) {
sql += " AND appName = ?";
params.push(appFilter);
}
sql += " ORDER BY timestamp DESC";
const rows = db.prepare(sql).all(...params);
const results = [];
for (const row of rows) {
const stored = readEmbeddingFromBlob(row.embedding);
if (!stored) continue;
let dot = 0;
for (let i = 0; i < queryEmbedding.length; i++) dot += queryEmbedding[i] * stored[i];
if (dot > 0.3) {
results.push({
screenshotId: row.id,
similarity: Math.round(dot * 1000) / 1000,
timestamp: row.timestamp,
appName: row.appName,
windowTitle: row.windowTitle,
ocrPreview: row.ocrPreview,
});
}
}
results.sort((a, b) => b.similarity - a.similarity);
return JSON.stringify({
query: searchQuery,
days,
totalScanned: rows.length,
matchesAboveThreshold: results.length,
results: results.slice(0, 15),
});
}
// --- Define MCP Tools using Agent SDK ---
const executeSqlTool = tool(
"execute_sql",
`Run SQL on the user's local omi.db SQLite database for structured data queries.
Use when:
- User asks for app usage stats, screen time, or activity counts
- Time-based queries like "how long did I spend on X?"
- Task management: looking up action items, checking completion status
- Aggregations, rankings, or structured filters on local data
Don't use when (if those tools are available):
- User asks about conversation content or transcripts (prefer get_conversations or search_conversations)
- User asks about their preferences or facts about themselves (prefer get_memories)
- User asks fuzzy/conceptual questions (use semantic_search instead)
- If backend tools are not available, fall back to execute_sql on the local transcription_sessions table
Note: Database is read-only (SELECT only). SELECT queries auto-limit to 200 rows.
Supports FTS5 MATCH queries for keyword search (e.g., WHERE screenshots_fts MATCH 'keyword').
BATCHING: when you need several independent queries (comparisons, multiple
tables, multiple time ranges), pass them ALL in the "queries" array in ONE
call — results come back labeled per query. Never issue sequential
execute_sql calls for queries that don't depend on each other's results.
Key tables: screenshots (appName, windowTitle, ocrText, timestamp), transcription_sessions (title, overview, startedAt, finishedAt), transcription_segments (sessionId, speaker, text, startTime), action_items (description, completed, priority, dueAt, category), memories (content, category, source), staged_tasks (description, priority, source), focus_sessions (status, appOrSite, durationSeconds), observations (appName, contextSummary, currentActivity), goals (title, goalType, targetValue, currentValue), indexed_files (path, filename, fileType, folder), live_notes (sessionId, text, timestamp), ai_user_profiles (profileText, generatedAt).`,
{
query: z.string().optional().describe("Single SQL query to execute against omi.db"),
queries: z
.array(z.string())
.min(1)
.max(8)
.optional()
.describe("Batch of independent SQL queries executed in one call; results return labeled in order"),
},
async ({ query, queries }) => {
const batch = queries ?? (query !== undefined ? [query] : []);
if (batch.length === 0) {
return { content: [{ type: "text", text: JSON.stringify({ error: "Provide query or queries" }) }] };
}
console.log(`[sql] calls=1 statements=${batch.length}`);
const result = batch.length === 1 ? executeSqlQuery(batch[0]) : runSqlBatch(executeSqlQuery, batch);
return { content: [{ type: "text", text: result }] };
}
);
const semanticSearchTool = tool(
"semantic_search",
`Vector similarity search on the user's screen history (what they saw on their computer).
Use when:
- Fuzzy or conceptual queries where exact SQL keywords won't work
- User asks "when was I reading about X?" or "find where I was working on Y"
- Theme-based recall: "design mockups", "code reviews", "email about project Z"
Don't use when:
- User asks about spoken conversations or transcripts (prefer search_conversations if available)
- User asks for structured counts or stats (use execute_sql)
- User wants a broad daily recap (use get_daily_recap)
Parameter guidance:
- days: Start with 7 (default). Use 1-3 for recent activity, 14-30 for older searches.
- app_filter: Set when user specifies an app (e.g., "in Chrome", "in VS Code"). Omit for cross-app searches.
- Results are ranked by semantic similarity — top 15 returned.`,
{
query: z.string().describe("Natural language search query describing what the user was doing or viewing"),
days: z.number().optional().default(7).describe("Days to search back: 1-3 for recent, 7 default, 14-30 for older"),
app_filter: z.string().optional().describe("Filter to a specific app (e.g., 'Chrome', 'VS Code'). Omit for all apps"),
},
async ({ query, days, app_filter }) => {
const result = await performSemanticSearch(query, days, app_filter);
return { content: [{ type: "text", text: result }] };
}
);
const getDailyRecapTool = tool(
"get_daily_recap",
`Get a pre-formatted daily activity recap combining app usage, conversations, and tasks.
ONE call covers any date range — pass the full range instead of calling once per day.
Use when:
- User asks "what did I do today/yesterday/this week?"
- Broad activity summaries or daily reviews
- User wants a quick overview without specifying a topic
Don't use when:
- User asks about a specific topic or event (prefer search_conversations if available)
- User needs detailed transcript content (prefer get_conversations if available)
- User wants structured data or counts (use execute_sql)
This tool runs three queries in one call (apps, conversations, tasks) — much faster than multiple execute_sql calls.
ONE call covers any date range. If the range has no data, the response says so authoritatively — do not re-verify with SQL.
Parameter guidance:
- start_date/end_date (YYYY-MM-DD): a specific day or range, e.g. "what did I do on July 15" → start_date=2026-07-15
- days_ago=0: today so far; days_ago=1: yesterday (default); days_ago=7: past week`,
{
days_ago: z.number().optional().default(1).describe("0=today, 1=yesterday, 7=past week. Ignored when start_date is set"),
start_date: z.string().optional().describe("Range start, YYYY-MM-DD. Use for specific dates"),
end_date: z.string().optional().describe("Range end (inclusive), YYYY-MM-DD. Defaults to start_date"),
},
async ({ days_ago, start_date, end_date }) => {
if (!db) return { content: [{ type: "text", text: "Database not loaded." }] };
let range;
try {
range = resolveRange({ days_ago, start_date, end_date });
} catch (e) {
return { content: [{ type: "text", text: `Error: ${e.message}` }] };
}
const counts = activityCounts(db, range);
const empty = emptyRangeStatement(range, counts);
if (empty) return { content: [{ type: "text", text: empty }] };
// App usage
const apps = db.prepare(`
SELECT appName, COUNT(*) as screenshots, ROUND(COUNT(*) * 10.0 / 60, 1) as minutes,
MIN(time(timestamp)) as first_seen, MAX(time(timestamp)) as last_seen
FROM screenshots
WHERE timestamp >= ? AND timestamp < ?
AND appName IS NOT NULL AND appName != ''
GROUP BY appName ORDER BY screenshots DESC
`).all(range.start, range.endExclusive);
// Conversations
const convos = db.prepare(`
SELECT title, overview, emoji, category, startedAt, finishedAt,
ROUND((julianday(finishedAt) - julianday(startedAt)) * 1440, 1) as duration_min
FROM transcription_sessions
WHERE startedAt >= ? AND startedAt < ?
AND deleted = 0 AND discarded = 0
ORDER BY startedAt DESC
`).all(range.start, range.endExclusive);
// Action items
const tasks = db.prepare(`
SELECT description, completed, priority, createdAt FROM action_items
WHERE createdAt >= ? AND createdAt < ?
AND deleted = 0
ORDER BY createdAt DESC
`).all(range.start, range.endExclusive);
const dateLabel = range.label;
// Format compact markdown
let out = `# ${dateLabel} Recap\n\n`;
out += `## Apps (${apps.length} apps)\n`;
if (apps.length === 0) {
out += "No screen activity recorded.\n";
} else {
for (const a of apps.slice(0, 20)) {
out += `- **${a.appName}**: ${a.minutes} min (${a.screenshots} captures, ${a.first_seen}–${a.last_seen})\n`;
}
if (apps.length > 20) out += `- ...and ${apps.length - 20} more apps\n`;
}
out += `\n## Conversations (${convos.length})\n`;
if (convos.length === 0) {
out += "No conversations recorded.\n";
} else {
for (const c of convos) {
const dur = c.duration_min > 0 ? ` (${c.duration_min} min)` : "";
const emoji = c.emoji || "";
out += `- ${emoji} **${c.title || "Untitled"}**${dur}: ${c.overview || "No summary"}\n`;
}
}
out += `\n## Tasks (${tasks.length})\n`;
if (tasks.length === 0) {
out += "No tasks created.\n";
} else {
for (const t of tasks) {
const check = t.completed ? "[x]" : "[ ]";
const pri = t.priority ? ` (${t.priority})` : "";
out += `- ${check} ${t.description}${pri}\n`;
}
}
// Short ranges get an hourly timeline + top window titles so one call
// answers "what was I doing" without follow-up SQL.
if (range.spanDays <= 2) {
out += formatHourlyTimeline(hourlyTimeline(db, range));
out += formatTopWindows(topWindows(db, range));
}
return { content: [{ type: "text", text: out }] };
}
);
const getAppUsageTool = tool(
"get_app_usage",
`Day-by-app screen activity matrix for any date range in ONE call.
Returns per-app totals plus a per-day breakdown (top apps per day).
Use for "what did I spend time on", day-vs-day comparisons, and usage patterns —
instead of hand-writing GROUP BY queries per day. Captures are ~10s apart.`,
{
start_date: z.string().describe("Range start, YYYY-MM-DD"),
end_date: z.string().optional().describe("Range end (inclusive), YYYY-MM-DD. Defaults to start_date"),
},
async ({ start_date, end_date }) => {
if (!db) return { content: [{ type: "text", text: "Database not loaded." }] };
let range;
try {
range = resolveRange({ start_date, end_date });
} catch (e) {
return { content: [{ type: "text", text: `Error: ${e.message}` }] };
}
return { content: [{ type: "text", text: formatAppUsage(range, appUsageMatrix(db, range)) }] };
}
);
// Real one-click connect link. When a connector reports "not connected", the
// agent should hand the user a real OAuth URL to click — not invent setup
// steps. This calls the backend's existing /v1/integrations/{app_key}/oauth-url
// (verified live) with the user's token and returns the real Google consent URL.
const getConnectLinkTool = tool(
"get_connect_link",
`Get a one-click link the user clicks to connect an integration to Omi.
Call this WHENEVER a connector/tool reports it is "not connected" (e.g. calendar,
gmail), or the user asks how to connect one. Return the real link to the user and
tell them to click it and sign in — NEVER describe manual setup steps or invent
other methods. integration is the requested capability, e.g. "gmail" or "calendar".`,
{ integration: z.string().describe('Integration capability, e.g. "gmail" or "calendar"') },
async ({ integration }) => {
if (!userFirebaseToken) {
return { content: [{ type: "text", text: "The user must be signed in to Omi before connecting integrations." }] };
}
try {
const resp = await fetch(`${BACKEND_URL}/v1/integrations/${encodeURIComponent(integration)}/oauth-url`, {
headers: { Authorization: `Bearer ${userFirebaseToken}` },
});
if (!resp.ok) {
const body = await resp.text();
return {
content: [
{
type: "text",
text:
`No one-click connect link is available for "${integration}" (HTTP ${resp.status}). ` +
`Tell the user this integration must be connected from Omi Settings → Apps & Integrations. Detail: ${body.slice(0, 200)}`,
},
],
};
}
const data = await resp.json();
const url = data.auth_url || data.url;
if (!url) return { content: [{ type: "text", text: `No link returned for "${integration}".` }] };
return {
content: [
{
type: "text",
text: `CONNECT LINK for ${integration}: ${url}\nGive this link to the user verbatim and tell them to click it and finish connecting; once done, ask them to try again.`,
},
],
};
} catch (err) {
return { content: [{ type: "text", text: `Error getting connect link for ${integration}: ${err.message}` }] };
}
}
);
// --- JSON Schema → Zod converter for backend tools ---
function jsonSchemaToZod(schema) {
const props = schema.properties || {};
const required = new Set(schema.required || []);
const shape = {};
for (const [name, prop] of Object.entries(props)) {
if (name === "config") continue; // internal LangChain param
let zodType;
// Handle anyOf (Optional fields from Pydantic)
const rawType = prop.type || (prop.anyOf ? prop.anyOf.find(t => t.type && t.type !== "null")?.type : "string");
switch (rawType) {
case "integer":
case "number":
zodType = z.number();
break;
case "boolean":
zodType = z.boolean();
break;
case "array":
zodType = z.array(z.any());
break;
default:
zodType = z.string();
}
if (prop.description) zodType = zodType.describe(prop.description);
// Pydantic Optional[X] emits anyOf [X, null]: accept explicit null too —
// models routinely pass null for "no filter", and rejecting it burned an
// error turn (measured in the eval corpus).
const acceptsNull = Array.isArray(prop.anyOf) && prop.anyOf.some((t) => t?.type === "null");
if (acceptsNull) zodType = zodType.nullable();
if (!required.has(name)) {
zodType = zodType.optional();
if (prop.default !== undefined && !(acceptsNull && prop.default === null)) {
zodType = zodType.default(prop.default);
}
}
shape[name] = zodType;
}
return shape;
}
// --- Fetch and register backend tools from Python API ---
async function fetchAndRegisterBackendTools() {
if (!userFirebaseToken) {
console.log("[backend-tools] No Firebase token, skipping tool fetch");
return;
}
try {
const resp = await fetch(`${BACKEND_URL}/v1/agent/tools`, {
headers: { Authorization: `Bearer ${userFirebaseToken}` },
});
if (!resp.ok) {
logEvent("error", "backend_tools_unavailable", { status: resp.status });
const timer = setTimeout(() => { fetchAndRegisterBackendTools(); }, 30_000);
timer.unref?.();
return;
}
const data = await resp.json();
const toolDefs = data.tools || [];
console.log(`[backend-tools] Fetched ${toolDefs.length} tools from Python backend`);
// Per-def isolation: one bad schema must not throw away every backend
// tool (the API also rejects the WHOLE request over one invalid tool name,
// so names are validated here at fetch time). First name wins on dupes —
// the 4 local tools claim theirs first in rebuildMcpServer.
const seenNames = new Set();
backendTools = toolDefs.flatMap((def) => {
try {
if (!/^[a-zA-Z0-9_-]{1,100}$/.test(def.name ?? "")) {
logEvent("warn", "backend_tool_skipped", { name: String(def.name), reason: "invalid_name" });
return [];
}
if (seenNames.has(def.name)) {
logEvent("warn", "backend_tool_skipped", { name: def.name, reason: "duplicate" });
return [];
}
seenNames.add(def.name);
const zodShape = jsonSchemaToZod(def.parameters || {});
return [tool(
def.name,
def.description || `Backend tool: ${def.name}`,
zodShape,
async (params) => {
try {
// User-blocking: retry transient failures with backoff.
const result = await withRetry(async () => {
const execResp = await fetch(`${BACKEND_URL}/v1/agent/execute-tool`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${userFirebaseToken}`,
},
body: JSON.stringify({ tool_name: def.name, params }),
});
if (!execResp.ok) {
throw new Error(`execute-tool HTTP ${execResp.status}`);
}
return await execResp.json();
}, { attempts: 3 });
if (result.error) {
return { content: [{ type: "text", text: `Error: ${result.error}` }] };
}
const bounded = truncateToolResult(result.result || JSON.stringify(result), def.name);
return { content: [{ type: "text", text: bounded.text }] };
} catch (err) {
logEvent("error", "backend_tool_failed", { name: def.name, error: err });
return { content: [{ type: "text", text: `Error calling ${def.name}: ${classifyError(err).category}` }] };
}
}
)];
} catch (err) {
logEvent("warn", "backend_tool_skipped", { name: String(def?.name), reason: "schema_conversion", error: err });
return [];
}
});
// Rebuild MCP server with all tools
rebuildMcpServer();
console.log(`[backend-tools] Registered ${backendTools.length} backend tools`);
} catch (err) {
logEvent("error", "backend_tools_unavailable", { error: err });
// A boot-time blip must not lose calendar/gmail for the VM's lifetime.
const timer = setTimeout(() => { fetchAndRegisterBackendTools(); }, 30_000);
timer.unref?.();
}
}
function rebuildMcpServer() {
const allTools = [
executeSqlTool,
semanticSearchTool,
getDailyRecapTool,
getAppUsageTool,
getConnectLinkTool,
...backendTools,
];
omiServer = createSdkMcpServer({
name: "omi-tools",
tools: allTools,
});
console.log(`[mcp] Rebuilt MCP server with ${allTools.length} tools`);
}
// --- Shared Agent Query Handler ---
async function handleQuery({ prompt, systemPrompt, cwd, send, abortController }) {
if (!isDatabaseReady()) {
send({ type: "error", message: "Database not available. Upload omi.db first." });
return { text: "", sessionId: "", costUsd: 0 };
}
let sessionId = "";
let fullText = "";
let costUsd = 0;
const pendingTools = [];
const options = {
model: "claude-opus-4-6",
abortController,
systemPrompt: systemPrompt || defaultSystemPrompt,
allowedTools: ALLOWED_TOOLS,
agents: agentDefinitions ?? undefined,
includePartialMessages: true,
permissionMode: "bypassPermissions",
allowDangerouslySkipPermissions: true,
maxTurns: 10,
cwd: cwd || process.env.HOME || "/",
mcpServers: {
"omi-tools": omiServer,
"playwright": {
command: process.execPath,
args: [
playwrightCli,
"--user-data-dir", join(__dirname, "chrome-profile"),
"--headless",
"--no-sandbox",
],
},
},
};
const q = query({ prompt, options });
const routeSubagent = createSubagentRouter();
for await (const message of q) {
if (abortController.signal.aborted) break;
// Subagent-origin messages: tool starts + throttled text snippets as
// progress; their text never enters the answer stream.
const subEvents = routeSubagent(message);
if (subEvents) {
for (const e of subEvents) send(e);
continue;
}
switch (message.type) {
case "system":
if ("session_id" in message) {
sessionId = message.session_id;
send({ type: "init", sessionId });
}
break;
case "stream_event": {
const event = message.event;
if (event?.type === "content_block_start" && event.content_block?.type === "tool_use") {
const name = event.content_block.name;
pendingTools.push(name);
send({ type: "tool_activity", name, status: "started" });
}
if (event?.type === "content_block_delta" && event.delta?.type === "text_delta") {
if (pendingTools.length > 0) {
for (const name of pendingTools) {
send({ type: "tool_activity", name, status: "completed" });
}
pendingTools.length = 0;
}
const text = event.delta.text;
fullText += text;
send({ type: "text_delta", text });
}
break;
}
case "assistant": {
// Fallback: if streaming didn't capture text (e.g. after tool calls),
// extract it from the complete assistant message
const content = message.message?.content;
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === "text" && typeof block.text === "string") {
// Check if this text was already sent via stream_event deltas
if (!fullText.includes(block.text)) {
fullText += block.text;
send({ type: "text_delta", text: block.text });
}
}
}
}
break;
}
case "result": {
for (const name of pendingTools) {
send({ type: "tool_activity", name, status: "completed" });
}
pendingTools.length = 0;
if (message.subtype === "success") {
costUsd = message.total_cost_usd || 0;
// Send any final text that wasn't captured during streaming
if (message.result) {
const remaining = message.result.replace(fullText, "").trim();
if (remaining) {
send({ type: "text_delta", text: remaining });
fullText += remaining;
}
}
} else {
const errors = message.errors || [];
{
const { category } = classifyError(new Error((message.errors || []).join(", ") || message.subtype));
logEvent("error", "agent_turn_error", { category, subtype: message.subtype, errors: message.errors });
send({ type: "error", code: category, message: USER_MESSAGES[category] ?? USER_MESSAGES.internal });
}
}
break;
}
}
}
return { text: fullText, sessionId, costUsd };
}
// --- Persistent Session (streaming input mode) ---
function startPersistentSession(initialSink, log, seedTurns = []) {
if (!isDatabaseReady()) {
log("Cannot start persistent session: database not ready");
return null;
}
// Turn history for non-destructive restart. When the SDK session dies (prompt
// too long / maxTurns), the recreated session was previously total amnesia;
// now the caller passes the prior turns and we seed the first prompt with a
// condensed view (recent turns verbatim) so context survives the restart.
const turns = [];
let currentUser = null; // raw user prompt of the in-flight turn
let seedPending = seedTurns.length ? seedTurns : null;
// The session outlives any single WebSocket connection (single-user VM):
// the client sink is swappable so a reconnect reattaches to the live
// session and its context instead of starting from amnesia. Events emitted
// while detached are dropped; the turn keeps running and its full text
// still arrives in the result after reattach.
let currentSink = initialSink;
let lastSentAt = Date.now();
const send = (msg) => {
lastSentAt = Date.now();
if (currentSink) currentSink(msg);
};
// Heartbeat: bound the client-visible silent gap during active turns.
// Measured (2026-07-22): with streaming on, light turns gap <3s but heavy
// delegated turns still go 40-64s silent while the subagent generates text
// we deliberately swallow. A periodic status caps the gap regardless of
// where the silence comes from.
const heartbeatMs = parseInt(process.env.OMI_TURN_HEARTBEAT_MS || "10000", 10);
const heartbeatTimer = setInterval(() => {
if (!turnActive || isPrewarmTurn) return;
if (Date.now() - lastSentAt < heartbeatMs) return;
const elapsedS = turnStartedAt ? Math.round((Date.now() - turnStartedAt) / 1000) : 0;
send({ type: "status", message: `Still working… (${elapsedS}s)` });
}, Math.max(50, Math.floor(heartbeatMs / 2)));
heartbeatTimer.unref?.();
const sessionAbort = new AbortController();
const options = {
model: "claude-sonnet-4-6",
abortController: sessionAbort,
systemPrompt: defaultSystemPrompt,
allowedTools: ALLOWED_TOOLS,
agents: agentDefinitions ?? undefined,
includePartialMessages: true,
permissionMode: "bypassPermissions",
allowDangerouslySkipPermissions: true,
maxTurns: 10,
cwd: process.env.HOME || "/",
mcpServers: {
"omi-tools": omiServer,
"playwright": {
command: process.execPath,
args: [playwrightCli, "--user-data-dir", join(__dirname, "chrome-profile"), "--headless", "--no-sandbox"],
},
},
};
// Queue for all messages — used as the prompt (async iterable)
const messageQueue = new AsyncMessageQueue();
// Push the prewarm message to boot the Claude process
messageQueue.push({
type: "user",
message: { role: "user", content: "ready" },
parent_tool_use_id: null,
session_id: "",
});
log(`Starting persistent session with model: ${options.model}`);
const q = query({ prompt: messageQueue, options });
// Session state
let sessionId = null;
let sessionIdResolve = null;
const sessionIdReady = new Promise(r => { sessionIdResolve = r; });
// Per-turn state
let fullText = "";
let pendingTools = [];
let turnActive = false;
let isPrewarmTurn = true; // First turn is prewarm, suppress output
let turnStartedAt = null; // timestamp when query was pushed
let firstEventSent = false; // tracks if we've sent the first stream event for this turn
let interruptRequested = false; // set when a stop/preemption cuts the current turn short
let dead = false; // set when the session loop dies — callers must start a fresh session
let pendingPrompt = null; // queue-of-1: a query arriving mid-turn supersedes any earlier pending one
function beginTurn(prompt, sid) {
turnActive = true;
turnStartedAt = Date.now();
firstEventSent = false;
currentUser = prompt;
let content = prompt;
// First turn of a reseeded session: prepend the condensed prior context so
// the restart is not amnesia. The user's raw prompt is still what we track.
if (seedPending) {
const summary = seedPending
.slice(0, Math.max(0, seedPending.length - 3))
.map((t) => `- ${t.user.slice(0, 100)} → ${(t.assistant || "").slice(0, 100)}`)
.join("\n") || "(no older turns)";
content = `${buildCondensedSeed(summary, seedPending.slice(-3))}\n\n---\nUser: ${prompt}`;
log(`Reseeded session with ${seedPending.length} prior turns (condensed)`);
seedPending = null;
}
send({ type: "status", message: "Processing..." });
messageQueue.push({
type: "user",
message: { role: "user", content },
parent_tool_use_id: null,
session_id: sid,
});
}
// Background message processing loop — runs for entire WS lifetime
const routeSubagent = createSubagentRouter();
const loopPromise = (async () => {
try {
for await (const message of q) {
// Subagent-origin messages: tool starts + throttled text snippets as
// progress; their text never enters the answer stream.
const subEvents = routeSubagent(message);
if (subEvents) {
if (!isPrewarmTurn) for (const e of subEvents) send(e);
continue;
}
switch (message.type) {
case "system":
if ("session_id" in message) {
sessionId = message.session_id;
sessionIdResolve?.();
sessionIdResolve = null;
log(`Persistent session started: ${sessionId}`);
}
break;
case "stream_event": {
if (isPrewarmTurn) break; // Suppress prewarm output
const event = message.event;
// Emit thinking_done on first content block (text or tool_use)
if (!firstEventSent && event?.type === "content_block_start") {
firstEventSent = true;