forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdb.ts
More file actions
2303 lines (2116 loc) · 87.9 KB
/
Copy pathdb.ts
File metadata and controls
2303 lines (2116 loc) · 87.9 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
import Database from 'better-sqlite3'
import { app, BrowserWindow } from 'electron'
import { basename, dirname, join } from 'path'
import { categorize } from '../usage/category'
import { isNewLocalDay } from '../usage/usageDay'
import { buildRewindFtsMatch } from '../rewind/rewindSearchQuery'
import { addColumnIfMissing as ensureColumn, runMigrations } from './dbMigrations'
import { applyRewindEmbeddingSchema } from './rewindEmbeddingSchema'
import { LOCAL_CONVERSATION_SCHEMA } from './localConversationSchema'
import {
clearCorruptionFlags,
isCorruptionError,
isCorruptionSuspected,
markCorruptionSuspected,
NO_RECOVERY,
openDatabaseWithRecovery,
repairSuspectedCorruption,
type RecoveryDb,
type RecoveryDriver,
type RecoveryStatus
} from './dbRecovery'
import { captureError } from '../sentry'
import { wipeUserDataOn } from './dbWipe'
import { ByokKeyStore } from '../agentKernel/byokStore'
import { McpKeyStore } from '../mcp/mcpKeyStore'
import {
insertVoiceTurnOn,
listPendingVoiceTurnsOn,
markVoiceTurnAckedOn,
recordVoiceTurnFailureOn,
type VoiceTurnOutboxDb
} from './voiceTurnOutbox'
import { bufferToVector, vectorToBuffer } from './taskEmbeddingVector'
import {
TASK_TABLES_SCHEMA,
insertLocalActionItemOn,
getLocalActionItemsOn,
getRecentActiveActionItemsOn,
getFilteredActionItemsOn,
updateCompletionStatusOn,
updateActionItemFieldsOn,
deleteActionItemByBackendIdOn,
markSyncedActionItemOn,
syncTaskActionItemsOn,
hardDeleteAbsentTasksOn,
hardDeleteAbsentCompletedTasksOn,
getUnsyncedActionItemsOn,
getAllActionItemEmbeddingsOn,
updateActionItemEmbeddingOn,
getActionItemsMissingEmbeddingsOn,
insertActionItemWithScoreShiftOn,
applyActionItemRerankingOn,
getTopRelevanceActionItemsOn,
searchActionItemsFTSOn,
insertLocalStagedTaskOn,
insertStagedTaskWithScoreShiftOn,
markSyncedStagedTaskOn,
deleteStagedTaskByIdOn,
deleteStagedTaskByBackendIdOn,
getUnsyncedStagedTasksOn,
getAllStagedTasksOn,
getAllScoredStagedTasksOn,
getStagedTaskOn,
getAllStagedTaskEmbeddingsOn,
updateStagedTaskEmbeddingOn,
getStagedTasksMissingEmbeddingsOn,
applyStagedTaskRerankingOn,
countActiveStagedTasksOn,
searchStagedTasksFTSOn,
type TaskStoreDb
} from './taskStore'
import {
INSIGHTS_SCHEMA,
insertInsightOn,
recentInsightsOn,
dismissInsightOn,
dismissAllInsightsOn,
clearInsightsOn,
type InsightStoreDb
} from './insightStore'
import {
LIVE_NOTES_SCHEMA,
createTranscriptionSessionOn,
endTranscriptionSessionOn,
createLiveNoteOn,
updateLiveNoteOn,
deleteLiveNoteOn,
listLiveNotesOn,
type LiveNotesDb
} from './liveNotesStore'
import {
listConversationFoldersOn,
replaceConversationFoldersOn,
upsertConversationFolderOn,
deleteConversationFolderOn,
type ConversationFoldersDb
} from './conversationFolders'
import { scanTopKBySimilarity } from '../rewind/embedVector'
// The privacy/backfill/scan SQL lives in one importable module so production and
// the SQL tests run byte-identical statements — a re-declared test copy drifts
// (it did, twice). See rewindEmbeddingSql.ts.
import {
DROP_ORPHANED_EMBEDDING_MAPPINGS_SQL,
DROP_ORPHANED_EMBEDDING_VECTORS_SQL,
REWIND_COLUMNS_QUALIFIED,
rewindFramesNeedingEmbeddingSql,
searchEmbeddingPageSql
} from './rewindEmbeddingSql'
import {
REWIND_SAMPLE_TARGET,
REWIND_DAY_COUNT_SQL,
rewindSampleStep,
buildRewindSampledSql
} from './rewindSampleSql'
import type {
AiUserProfileInput,
AiUserProfileRecord,
FocusSessionInput,
FocusSessionRecord,
MemoryInput,
ActionItemInput,
ActionItemRecord,
StagedTaskInput,
StagedTaskRecord,
SyncActionItem,
MarkSyncedResult,
TaskRerank,
AppUsageRecord,
ChatMessage,
ConversationFolder,
ConversationSyncPatch,
ConversationSyncState,
FileIndexDigest,
IndexedAppRecord,
IndexedFileRecord,
InsightPayload,
InsightRecord,
KgSqlResult,
LiveNote,
KnowledgeGraph,
LocalConversation,
LocalKGStatus,
LocalKnowledgeGraph,
OnboardingGraphNode,
OnboardingGraphEdge,
OcrLine,
RewindFrame,
SyncSegment,
UsageCategory,
VoiceTurnOutboxEntry,
VoiceTurnOutboxInput
} from '../../shared/types'
import { perfMark } from '../../shared/perf'
import { cachedStmt } from './stmtCache'
// Time a synchronous DB helper and emit a perf mark with its duration in ms.
// Always-on (perfMark is a no-op unless OMI_PERF_LOG is set), so the bench can
// measure DB read throughput without affecting normal runs.
function timed<T>(name: string, fn: () => T): T {
const t = performance.now()
try {
return fn()
} finally {
perfMark(`db:${name}`, { ms: performance.now() - t })
}
}
let db: Database.Database | null = null
let roDb: Database.Database | null = null
// (ensureColumn — add a column only if missing, so existing databases migrate
// forward without data loss — is dbMigrations.addColumnIfMissing, shared with
// the versioned migrations so the idiom exists once.)
// Drop a table whose on-disk schema predates the current one (detected by a
// missing expected column), so the CREATE TABLE IF NOT EXISTS below can recreate
// it fresh. Used for the local_kg_* tables: an abandoned experiment left an
// incompatible schema (node_id/edge_id PKs, no summary/source columns) that
// silently broke every INSERT. These tables are a derived cache with no user
// data worth migrating, so recreating them is safe.
function dropIfMissingColumn(d: Database.Database, table: string, col: string): void {
const exists = cachedStmt(d, "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(
table
)
if (!exists) return
const cols = d.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]
if (!cols.some((c) => c.name === col)) d.exec(`DROP TABLE ${table}`)
}
// OMI_DB_PATH lets the bench harness point at a throwaway DB so benchmarking
// never reads or writes the user's real omi.db.
function dbFilePath(): string {
return process.env.OMI_DB_PATH ?? join(app.getPath('userData'), 'omi.db')
}
// Corrupt originals are archived next to the database (macOS: <dataDir>/backups),
// keyed off the db file so a bench/test DB keeps its backups in its own temp dir.
function backupsDir(): string {
return join(dirname(dbFilePath()), 'backups')
}
// The production driver for dbRecovery's seam. The casts bridge better-sqlite3's
// generically-typed statement methods to the structural RecoveryDb surface —
// same duck-typing idiom as voiceTurnDb() below and the node:sqlite test drivers.
const betterSqliteDriver: RecoveryDriver = {
open: (file) => new Database(file) as unknown as RecoveryDb,
openReadonly: (file) =>
new Database(file, { readonly: true, fileMustExist: true }) as unknown as RecoveryDb
}
let recoveryStatus: RecoveryStatus = NO_RECOVERY
// --- The runtime corruption trip ---------------------------------------------
//
// A damaged DATA page is invisible to the startup open+sanity check: the DB opens,
// the schema reads, and only the damaged table throws SQLITE_CORRUPT — every time
// it is queried, forever. Without this trip nothing would ever notice, and the
// salvage engine could never run on the one class of corruption where it saves the
// user's data (measured: sibling tables intact, ~99% of the damaged table's rows
// still recoverable).
//
// So: arm every statement on the shared connection. A corrupt error from ANY live
// query persists a suspicion flag and asks the user to restart; the repair itself
// runs at the next startup, where it is safe. The error is always RETHROWN — this
// observes, it never swallows.
//
// macOS has the same design (reportQueryError -> maxQueryIOErrorsBeforeRecovery)
// with zero callers. This is the wiring it never got.
let corruptionNoticed = false
/** Persist the suspicion and tell the user, once per session. Never throws: it
* runs from inside a failing query's catch block. */
function noteCorruption(handle: Database.Database, err: unknown): void {
if (corruptionNoticed) return
corruptionNoticed = true
console.error('db: a live query raised a corruption error — flagging for repair on restart', err)
captureError(err, { area: 'db_corruption_runtime', extra: { file: dbFilePath() } })
try {
markCorruptionSuspected(handle as unknown as RecoveryDb)
} catch {
// Too damaged even to record it; the startup detector covers that class.
}
// Ask the user to restart. The repair cannot run now — the KG worker and the
// read-only handle are live, and replacing the file under them would strand them.
for (const w of BrowserWindow.getAllWindows()) {
if (!w.isDestroyed()) w.webContents.send('db:corruption-detected')
}
}
/**
* Wrap `prepare`/`exec` so a corrupt error from any query trips the flag. The
* error is rethrown unchanged, so caller behavior is identical — this is a pure
* observer on the failure path, and adds nothing to the success path beyond a
* try/catch.
*/
function armCorruptionTrip(handle: Database.Database): Database.Database {
const watch = <T>(run: () => T): T => {
try {
return run()
} catch (err) {
if (isCorruptionError(err)) noteCorruption(handle, err)
throw err
}
}
const originalPrepare = handle.prepare.bind(handle)
handle.prepare = ((sql: string) => {
const stmt = watch(() => originalPrepare(sql))
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- driver methods are variadic/overloaded
const raw = stmt as any
for (const method of ['all', 'get', 'run', 'iterate', 'pluck'] as const) {
const original = raw[method]
if (typeof original !== 'function') continue
raw[method] = (...args: unknown[]): unknown =>
watch(() => (original as (...a: unknown[]) => unknown).apply(stmt, args))
}
return stmt
}) as typeof handle.prepare
const originalExec = handle.exec.bind(handle)
handle.exec = ((sql: string) => watch(() => originalExec(sql))) as typeof handle.exec
return handle
}
/** What happened to the database on this launch: whether corruption was detected,
* how many rows were salvaged, and whether it had to be reset. Surfaced to the
* user over IPC (`db:recoveryStatus`) — unlike macOS, whose equivalent flag is
* declared but never set, so its recovery UI can never fire. */
export function getDbRecoveryStatus(): RecoveryStatus {
return recoveryStatus
}
/**
* Open the database (recovering it first if it is corrupt) before anything else
* touches it. Called once at startup.
*
* This must run before the KG write worker (`kgWorker.ts`, which opens its OWN
* better-sqlite3 handle to the same path in a worker_thread) and before the
* read-only `roDb` handle below. Recovery replaces the file on disk; doing that
* under a live handle would leave that handle pointing at a deleted inode. All of
* those open lazily and later, so running recovery here — single-threaded, before
* any window exists — is what makes the swap safe by construction.
*/
export function initDatabase(): RecoveryStatus {
get()
return recoveryStatus
}
function get(): Database.Database {
if (db) return db
const file = dbFilePath()
const backups = backupsDir()
const log = (m: string): void => console.log(m)
const reopen = (): RecoveryDb =>
openDatabaseWithRecovery(file, betterSqliteDriver, { backupsDir: backups }).db
// Detect + recover corruption BEFORE any schema work. A healthy database is
// opened untouched; only a positively-classified corrupt one is backed up,
// salvaged and replaced. See dbRecovery.ts.
const opened = openDatabaseWithRecovery(file, betterSqliteDriver, {
backupsDir: backups,
hooks: {
log,
onCorruption: (err) => {
// Silent UX healing is fine; silent ops is not (AGENTS.md). No Windows
// recordFallback emitter exists, so this is console + Sentry.
console.error('db: CORRUPTION DETECTED in omi.db — recovering', err)
captureError(err, { area: 'db_corruption', extra: { file } })
}
}
})
let handle = opened.db
recoveryStatus = opened.status
// The next-launch half of the runtime trip: a previous session saw a live query
// raise a corrupt error and flagged it. The flag is only a SUSPICION — repair
// re-verifies that the damage still reproduces, refuses to rebuild into a worse
// state, and gives up after MAX_REPAIR_ATTEMPTS rather than looping forever.
if (!recoveryStatus.recovered && isCorruptionSuspected(handle)) {
const hooks = {
log,
onCorruption: (err: unknown) => {
console.error('db: corruption confirmed on restart — repairing', err)
captureError(err, { area: 'db_corruption_confirmed', extra: { file } })
}
}
const outcome = repairSuspectedCorruption(handle, file, betterSqliteDriver, {
backupsDir: backups,
hooks
})
if (outcome.action === 'repaired') {
handle = reopen()
recoveryStatus = outcome.status
// The salvage copied app_meta across, flag and all — clear it on the repaired DB.
clearCorruptionFlags(handle)
} else if (outcome.action === 'abandoned' || outcome.action === 'kept_original') {
// Confirmed damage we deliberately did NOT rebuild. Leave the DB alone, tell
// the user, and report — never silently keep limping.
const reason =
outcome.action === 'abandoned'
? `repair budget exhausted after ${outcome.attempts} attempts`
: // kept_original covers two safe-direction outcomes: a rebuild would have
// lost rows a working table still serves, OR the corrupt file could not
// be moved aside so we refused to touch it. Either way, nothing changed.
'a safe rebuild was not possible (would lose readable rows, or the corrupt file could not be archived)'
console.error(`db: corruption confirmed but NOT repaired — ${reason}`)
captureError(new Error(`db corruption unrepaired: ${reason}`), {
area: 'db_corruption_unrepaired',
extra: { file, damaged: outcome.damaged }
})
recoveryStatus = {
...NO_RECOVERY,
unrepairable: true,
damagedTables: outcome.damaged,
backupPath: outcome.action === 'kept_original' ? outcome.backupPath : null
}
// The handle was closed by the repair on the kept_original path; reopen.
if (outcome.action === 'kept_original') {
handle = reopen()
}
}
// 'no_repair_needed' (false alarm) leaves the handle and the DB untouched.
}
// Arm the trip so a corrupt error from any live query flags the DB for repair at
// the next launch. Must wrap the FINAL handle (post-repair).
db = armCorruptionTrip(handle as unknown as Database.Database)
// WAL mode: allows main-thread reads to proceed concurrently while the KG
// write worker holds the write lock. Synchronous stays at the default FULL so
// non-KG tables (local_conversation etc.) are not at power-loss risk.
// The worker sets synchronous=NORMAL only on its own connection.
db.pragma('journal_mode = WAL')
// Wait out a concurrent writer (the KG worker) instead of failing with
// SQLITE_BUSY. macOS sets the same 5s timeout.
db.pragma('busy_timeout = 5000')
// Read-only speed pragmas (pure perf, zero durability tradeoff — unlike
// synchronous above, which deliberately stays FULL). temp_store=MEMORY keeps
// temp b-trees (ORDER BY / GROUP BY / transient indexes) off disk; cache_size
// negative is KiB, so -64000 = 64 MiB of page cache for read-heavy IPC.
db.pragma('temp_store = MEMORY')
db.pragma('cache_size = -64000')
// Migrate away the incompatible local_kg_* schema from the parked KG experiment.
dropIfMissingColumn(db, 'local_kg_nodes', 'summary')
dropIfMissingColumn(db, 'local_kg_edges', 'id')
// PR8 LiveNotes: PR0 shipped a dead, FK-less `live_notes` (no `updated_at`) and
// no `transcription_sessions`. The table has never held data, so drop the old
// shape and recreate it (below, via LIVE_NOTES_SCHEMA) with the cascading FK +
// `updated_at`, mirroring the macOS schema.
dropIfMissingColumn(db, 'live_notes', 'updated_at')
// Track 4 (Rewind semantic search): drop-then-create, as one ordered unit, in a
// module the schema tests can actually load. See rewindEmbeddingSchema.ts — a
// PR0-era rewind_embeddings has no `hash` column, and indexing it would throw
// out of this bootstrap and take every db-backed IPC handler down with it.
applyRewindEmbeddingSchema(db)
db.exec(`
CREATE TABLE IF NOT EXISTS caption_event (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id TEXT NOT NULL,
ts INTEGER NOT NULL,
caption TEXT NOT NULL,
ocr_text TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_caption_convo ON caption_event(conversation_id, ts);
CREATE TABLE IF NOT EXISTS local_conversation (
id TEXT PRIMARY KEY,
started_at INTEGER NOT NULL,
ended_at INTEGER NOT NULL,
transcript TEXT NOT NULL,
created_at INTEGER NOT NULL,
kind TEXT NOT NULL DEFAULT 'recording',
messages TEXT,
title TEXT
);
CREATE TABLE IF NOT EXISTS indexed_files (
path TEXT PRIMARY KEY,
filename TEXT NOT NULL,
extension TEXT NOT NULL,
file_type TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
folder TEXT NOT NULL,
depth INTEGER NOT NULL,
created_at INTEGER NOT NULL,
modified_at INTEGER NOT NULL,
indexed_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_indexed_files_type ON indexed_files(file_type);
CREATE TABLE IF NOT EXISTS local_kg_nodes (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
node_type TEXT NOT NULL,
summary TEXT NOT NULL,
source TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_local_kg_nodes_label ON local_kg_nodes(label);
CREATE INDEX IF NOT EXISTS idx_local_kg_nodes_type ON local_kg_nodes(node_type);
CREATE TABLE IF NOT EXISTS local_kg_edges (
id TEXT PRIMARY KEY,
source_id TEXT NOT NULL,
target_id TEXT NOT NULL,
label TEXT NOT NULL,
created_at INTEGER NOT NULL
);
-- Onboarding brain-map graph (sandbox/ui). Separate tables from the chat-KG
-- local_kg_* above; disposable progressive-reveal data only.
CREATE TABLE IF NOT EXISTS onboarding_kg_nodes (
node_id TEXT PRIMARY KEY,
label TEXT NOT NULL,
node_type TEXT NOT NULL,
aliases_json TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS onboarding_kg_edges (
edge_id TEXT PRIMARY KEY,
source_id TEXT NOT NULL,
target_id TEXT NOT NULL,
label TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS app_usage (
exe_path TEXT PRIMARY KEY,
exe_name TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'other',
total_seconds INTEGER NOT NULL DEFAULT 0,
last_used INTEGER NOT NULL DEFAULT 0,
distinct_days INTEGER NOT NULL DEFAULT 0,
first_seen INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS rewind_frames (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
app TEXT NOT NULL DEFAULT '',
window_title TEXT NOT NULL DEFAULT '',
process_name TEXT NOT NULL DEFAULT '',
ocr_text TEXT NOT NULL DEFAULT '',
image_path TEXT NOT NULL,
width INTEGER NOT NULL DEFAULT 0,
height INTEGER NOT NULL DEFAULT 0,
indexed INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_rewind_frames_ts ON rewind_frames(ts);
CREATE INDEX IF NOT EXISTS idx_rewind_frames_indexed ON rewind_frames(indexed);
-- --- Track 4: Rewind FTS5 (full-text search over rewind_frames) ---
-- External-content FTS index mirroring rewind_frames(id): the triggers below
-- keep it in sync on every write, so search reads BM25-ranked matches without
-- a full-table LIKE scan. Existing rows are backfilled once by dbMigrations v2
-- (which runs AFTER this block — see runMigrations call in get()).
CREATE VIRTUAL TABLE IF NOT EXISTS rewind_frames_fts USING fts5(
ocr_text, window_title, app,
content='rewind_frames', content_rowid='id', tokenize='unicode61'
);
CREATE TRIGGER IF NOT EXISTS rewind_frames_ai AFTER INSERT ON rewind_frames BEGIN
INSERT INTO rewind_frames_fts(rowid, ocr_text, window_title, app)
VALUES (new.id, new.ocr_text, new.window_title, new.app);
END;
CREATE TRIGGER IF NOT EXISTS rewind_frames_ad AFTER DELETE ON rewind_frames BEGIN
INSERT INTO rewind_frames_fts(rewind_frames_fts, rowid, ocr_text, window_title, app)
VALUES ('delete', old.id, old.ocr_text, old.window_title, old.app);
END;
CREATE TRIGGER IF NOT EXISTS rewind_frames_au AFTER UPDATE ON rewind_frames BEGIN
INSERT INTO rewind_frames_fts(rewind_frames_fts, rowid, ocr_text, window_title, app)
VALUES ('delete', old.id, old.ocr_text, old.window_title, old.app);
INSERT INTO rewind_frames_fts(rowid, ocr_text, window_title, app)
VALUES (new.id, new.ocr_text, new.window_title, new.app);
END;
-- (Track 4's rewind_embeddings / rewind_embedding_vectors are created by
-- applyRewindEmbeddingSchema() above, not here — they need a drop-first
-- migration that must not be interleaved with this block.)
-- --- Track 4: Conversation folders ---
CREATE TABLE IF NOT EXISTS conversation_folders (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
color TEXT,
icon TEXT,
order_idx INTEGER NOT NULL DEFAULT 0,
is_system INTEGER NOT NULL DEFAULT 0,
conversation_count INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER
);
-- --- Track 4: Per-conversation speaker names ---
CREATE TABLE IF NOT EXISTS conversation_speaker_names (
conversation_id TEXT NOT NULL,
speaker_id INTEGER NOT NULL,
name TEXT,
person_id TEXT,
is_user INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (conversation_id, speaker_id)
);
-- --- PR8: LiveNotes tables (transcription_sessions + live_notes) are created
-- from LIVE_NOTES_SCHEMA below, not here — the DDL lives in liveNotesStore.ts
-- so production and the CRUD tests run byte-identical statements. ---
-- --- Track 4: Crash-rescue live-segment buffer ---
CREATE TABLE IF NOT EXISTS rescue_segments (
session_id TEXT NOT NULL,
seq INTEGER NOT NULL,
segment_json TEXT NOT NULL,
ts INTEGER NOT NULL,
PRIMARY KEY (session_id, seq)
);
-- --- Track 4: File-index scan state (last_scan_at per root, etc.) ---
CREATE TABLE IF NOT EXISTS file_index_meta (
key TEXT PRIMARY KEY,
value TEXT
);
-- --- Track 4: App-level flags (clean-exit, launch-at-login migrated). NOT
-- user-scoped — deliberately excluded from USER_DATA_TABLES so it survives
-- sign-out. ---
CREATE TABLE IF NOT EXISTS app_meta (
key TEXT PRIMARY KEY,
value TEXT
);
-- Track 2: Voice & PTT depth (voice turn outbox)
-- Durable outbox for a voice turn (PTT or realtime-session utterance) that
-- must survive an app restart mid-flight. Mirrors the macOS
-- RealtimeVoiceTurnOutbox 1:1: idempotency_key is the natural per-turn dedup
-- key (one UUID reused across the turn's completed / interrupted / optimistic
-- variants), a positive kernel ack deletes the row, and the drain scans
-- pending rows oldest-first. Unconsumed until Phase B / Track 1 wire the
-- kernel-write path — the table lands early to claim the shared additive file.
CREATE TABLE IF NOT EXISTS voice_turn_outbox (
idempotency_key TEXT PRIMARY KEY,
owner_id TEXT NOT NULL,
surface TEXT,
app_id TEXT,
session_id TEXT,
user_text TEXT,
assistant_text TEXT,
interrupted INTEGER NOT NULL DEFAULT 0,
created_at_ms INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
updated_at_ms INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_voice_turn_outbox_pending
ON voice_turn_outbox(status, created_at_ms);
`)
/* ---- Track 3 (proactive intelligence & memory) ---- */
// Net-new tables — CREATE TABLE IF NOT EXISTS only, no numbered migration, so
// sibling tracks never collide on a user_version bump. See shared/types.ts for
// the record shapes and the readers/writers at the end of this file.
db.exec(`
CREATE TABLE IF NOT EXISTS ai_user_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
profile_text TEXT NOT NULL,
data_sources_used TEXT,
generated_at INTEGER NOT NULL,
backend_synced INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_ai_user_profiles_generated_at ON ai_user_profiles(generated_at);
CREATE TABLE IF NOT EXISTS focus_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
screenshot_id TEXT,
status TEXT NOT NULL,
app_or_site TEXT,
description TEXT,
message TEXT,
duration_seconds INTEGER NOT NULL DEFAULT 0,
backend_id TEXT,
backend_synced INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
window_title TEXT
);
CREATE INDEX IF NOT EXISTS idx_focus_sessions_created_at ON focus_sessions(created_at);
CREATE TABLE IF NOT EXISTS memories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content TEXT NOT NULL,
category TEXT NOT NULL,
source_app TEXT NOT NULL DEFAULT '',
window_title TEXT NOT NULL DEFAULT '',
context_summary TEXT NOT NULL DEFAULT '',
confidence REAL,
screenshot_id INTEGER,
backend_id TEXT,
backend_synced INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_memories_created_at ON memories(created_at);
`)
// Track 3 local task storage (action_items + staged_tasks + their FTS indexes).
// DDL lives in taskStore.ts so prod and the node:sqlite CRUD tests run the same
// SQL; both tables are user-scoped (see USER_DATA_TABLES in dbWipe.ts).
db.exec(TASK_TABLES_SCHEMA)
// PR8 LiveNotes: transcription_sessions + live_notes (with the cascading FK).
// DDL lives in liveNotesStore.ts so prod and the CRUD tests run the same SQL;
// the drop-if-old above recreated any FK-less PR0 table before this runs.
db.exec(LIVE_NOTES_SCHEMA)
// Proactive Insights history. DDL lives in insightStore.ts so prod and the
// node:sqlite CRUD tests run the same SQL.
db.exec(INSIGHTS_SCHEMA)
// Migrate older databases that have local_conversation without these columns.
ensureColumn(db, 'local_conversation', 'kind', "TEXT NOT NULL DEFAULT 'recording'")
ensureColumn(db, 'local_conversation', 'messages', 'TEXT')
ensureColumn(db, 'local_conversation', 'title', 'TEXT')
// Node provenance for the LLM-synthesized graph (additive).
ensureColumn(db, 'local_kg_nodes', 'aliases_json', 'TEXT')
ensureColumn(db, 'local_kg_nodes', 'source_refs', 'TEXT')
// Resolved .lnk target exe, for joining indexed apps to app_usage (additive).
ensureColumn(db, 'indexed_files', 'target_path', 'TEXT')
// --- Track 4: additive columns on existing tables ---
// Per-line OCR bounding boxes (JSON) for a future on-image highlight overlay.
ensureColumn(db, 'rewind_frames', 'ocr_lines_json', 'TEXT')
// Conversation starring + folder assignment (local mirror of the cloud fields).
ensureColumn(db, 'local_conversation', 'starred', 'INTEGER NOT NULL DEFAULT 0')
ensureColumn(db, 'local_conversation', 'folder_id', 'TEXT')
// Index the created_at read order for listLocalConversations() (full scan +
// temp-b-tree sort without it). DDL lives in localConversationSchema.ts so prod
// and the query-plan test run the same statement. Runs after the base table +
// additive columns exist; created_at is a base column in every install.
db.exec(LOCAL_CONVERSATION_SCHEMA)
// (rewind_embeddings is migrated by migrateRewindEmbeddingSchema, BEFORE the
// exec above — an ensureColumn here would run far too late to save it.)
// Versioned migrations (PRAGMA user_version) — everything beyond the additive
// baseline above. Ordered + exactly-once; see dbMigrations.ts.
runMigrations(db)
// After a salvage the FTS index is empty: salvage skips virtual tables (copying
// FTS shadow tables raw would produce a corrupt index) and preserves
// user_version, so migration v2's backfill does not re-run. The bootstrap block
// above has just recreated the vtable + triggers, so rebuild the index from the
// recovered rewind_frames rows — same 'rebuild' idiom as migration v2. Never let
// this block startup.
if (recoveryStatus.recovered && !recoveryStatus.reset) {
// Rebuild every external-content FTS index from its recovered base rows (salvage
// skips virtual tables, leaving the shadow tables empty). Same 'rebuild' idiom as
// migration v2. Each is independent — one failing must not skip the others.
for (const fts of ['rewind_frames_fts', 'action_items_fts', 'staged_tasks_fts']) {
try {
db.exec(`INSERT INTO ${fts}(${fts}) VALUES('rebuild')`)
} catch (e) {
console.error(`db: FTS rebuild after recovery failed for ${fts} (search may be stale)`, e)
}
}
}
return db
}
type LocalConversationRow = {
id: string
startedAt: number
endedAt: number
transcript: string
createdAt: number
kind: string | null
messages: string | null
title: string | null
syncState: string | null
segmentsJson: string | null
cloudId: string | null
syncAttempts: number | null
syncError: string | null
}
const SYNC_STATES: ConversationSyncState[] = [
'local_only',
'pending',
'posting',
'done',
'failed',
'unconfirmed'
]
function parseSegments(json: string | null): SyncSegment[] | null {
if (!json) return null
try {
const v = JSON.parse(json)
return Array.isArray(v) ? (v as SyncSegment[]) : null
} catch {
return null
}
}
function mapLocalConversation(row: LocalConversationRow): LocalConversation {
return {
id: row.id,
startedAt: row.startedAt,
endedAt: row.endedAt,
transcript: row.transcript,
createdAt: row.createdAt,
kind: row.kind === 'chat' ? 'chat' : 'recording',
messages: row.messages ? (JSON.parse(row.messages) as ChatMessage[]) : undefined,
title: row.title ?? null,
syncState: SYNC_STATES.includes(row.syncState as ConversationSyncState)
? (row.syncState as ConversationSyncState)
: 'local_only',
// Tolerate a corrupt segments blob: one bad row must not throw and break the
// whole listLocalConversations() read.
segments: parseSegments(row.segmentsJson),
cloudId: row.cloudId ?? null,
syncAttempts: row.syncAttempts ?? 0,
syncError: row.syncError ?? null
}
}
const LOCAL_CONVERSATION_COLUMNS =
'id, started_at AS startedAt, ended_at AS endedAt, transcript, created_at AS createdAt, kind, messages, title, ' +
'sync_state AS syncState, segments_json AS segmentsJson, cloud_id AS cloudId, sync_attempts AS syncAttempts, sync_error AS syncError'
export function insertLocalConversation(c: LocalConversation): void {
cachedStmt(
get(),
'INSERT OR REPLACE INTO local_conversation (id, started_at, ended_at, transcript, created_at, kind, messages, title, sync_state, segments_json, cloud_id, sync_attempts, sync_error) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(
c.id,
c.startedAt,
c.endedAt,
c.transcript,
c.createdAt,
c.kind ?? 'recording',
c.messages ? JSON.stringify(c.messages) : null,
c.title ?? null,
c.syncState ?? 'local_only',
c.segments && c.segments.length > 0 ? JSON.stringify(c.segments) : null,
c.cloudId ?? null,
c.syncAttempts ?? 0,
c.syncError ?? null
)
}
/** Persist an outbox transition (see ConversationSyncState / lib/sync/outbox.ts).
* cloudId/syncError only change when present in the patch; incrementAttempts
* bumps the counter atomically with the state write. */
export function updateLocalConversationSync(id: string, patch: ConversationSyncPatch): void {
const sets = ['sync_state = ?']
const params: unknown[] = [patch.syncState]
if (patch.cloudId !== undefined) {
sets.push('cloud_id = ?')
params.push(patch.cloudId)
}
if (patch.syncError !== undefined) {
sets.push('sync_error = ?')
params.push(patch.syncError)
}
if (patch.incrementAttempts) sets.push('sync_attempts = sync_attempts + 1')
params.push(id)
get()
.prepare(`UPDATE local_conversation SET ${sets.join(', ')} WHERE id = ?`)
.run(...params)
}
/**
* Atomically claim a row for POSTing: flip it to 'posting' (and bump attempts)
* ONLY if it is still in a claimable state. Returns true iff this call won the
* claim. This is the compare-and-swap that makes the pending→posting transition
* safe against a stale-snapshot second driver (e.g. the Conversations retry pass
* running with a row it read before an earlier sync moved it on): the loser sees
* `changes === 0` and backs off instead of re-POSTing (which prod would
* duplicate, since it ignores client_session_id). 'posting' is intentionally
* excluded — a row already posting is owned by a live driver; a genuinely
* crash-orphaned 'posting' row is first recovered to 'unconfirmed' (which IS
* claimable) by the caller. Optionally resets sync_attempts (manual re-sync).
*/
export function claimConversationForPosting(id: string, resetAttempts = false): boolean {
const attemptsExpr = resetAttempts ? '1' : 'sync_attempts + 1'
const r = get()
.prepare(
`UPDATE local_conversation SET sync_state = 'posting', sync_attempts = ${attemptsExpr}
WHERE id = ? AND sync_state IN ('pending', 'failed', 'unconfirmed')`
)
.run(id)
return r.changes > 0
}
// --- Track 4: conversation folders / starred ---
// Thin wrappers over the driver-agnostic CRUD in conversationFolders.ts (extracted
// so the SQL is unit-testable under plain-node vitest with node:sqlite; see that
// file + its test). get() returns a better-sqlite3 Database whose prepared
// statements satisfy the ConversationFoldersDb shape structurally — cast to bridge
// the driver duck-typing, same idiom the voice-turn-outbox wrappers use.
function foldersDb(): ConversationFoldersDb {
return get() as unknown as ConversationFoldersDb
}
export function listConversationFolders(): ConversationFolder[] {
return listConversationFoldersOn(foldersDb())
}
export function replaceConversationFolders(folders: ConversationFolder[]): void {
replaceConversationFoldersOn(foldersDb(), folders)
}
export function upsertConversationFolder(folder: ConversationFolder): void {
upsertConversationFolderOn(foldersDb(), folder)
}
export function deleteConversationFolder(id: string): void {
deleteConversationFolderOn(foldersDb(), id)
}
export function updateLocalConversationTitle(id: string, title: string): void {
cachedStmt(get(), 'UPDATE local_conversation SET title = ? WHERE id = ?').run(
title.trim() || null,
id
)
}
// --- PR8: LiveNotes CRUD ---
// Thin wrappers over the driver-agnostic CRUD in liveNotesStore.ts (extracted so
// the SQL is unit-testable under plain-node vitest with node:sqlite). get()
// returns a better-sqlite3 Database whose prepared statements satisfy the
// LiveNotesDb shape structurally — same cast idiom as the folder wrappers.
function liveNotesDb(): LiveNotesDb {
return get() as unknown as LiveNotesDb
}
export function createTranscriptionSession(session: {
id: string
startedAt: number
createdAt: number
}): void {
createTranscriptionSessionOn(liveNotesDb(), session)
}
export function endTranscriptionSession(id: string, endedAt: number): void {
endTranscriptionSessionOn(liveNotesDb(), id, endedAt)
}
export function createLiveNote(note: LiveNote): void {
createLiveNoteOn(liveNotesDb(), note)
}
export function updateLiveNote(id: string, text: string, updatedAt: number): void {
updateLiveNoteOn(liveNotesDb(), id, text, updatedAt)
}
export function deleteLiveNote(id: string): void {
deleteLiveNoteOn(liveNotesDb(), id)
}
export function listLiveNotes(sessionId: string): LiveNote[] {
return listLiveNotesOn(liveNotesDb(), sessionId)
}
export function getLocalConversation(id: string): LocalConversation | null {
return timed('getLocalConversation', () => {
const row = cachedStmt(
get(),
`SELECT ${LOCAL_CONVERSATION_COLUMNS} FROM local_conversation WHERE id = ?`
).get(id) as LocalConversationRow | undefined
return row ? mapLocalConversation(row) : null
})
}
export function listLocalConversations(): LocalConversation[] {
return timed('listLocalConversations', () => {
const rows = cachedStmt(
get(),
`SELECT ${LOCAL_CONVERSATION_COLUMNS} FROM local_conversation ORDER BY created_at DESC`
).all() as LocalConversationRow[]
return rows.map(mapLocalConversation)
})
}
export function deleteLocalConversation(id: string): void {
cachedStmt(get(), 'DELETE FROM local_conversation WHERE id = ?').run(id)
}
export function remapConversationId(fromId: string, toId: string): number {
const r = cachedStmt(
get(),
'UPDATE caption_event SET conversation_id = ? WHERE conversation_id = ?'
).run(toId, fromId)
return r.changes
}
// Load path → modified_at (ms) for the whole index. Drives both the retention
// diff (which existing paths still exist on disk) and the incremental mtime-skip.
export function loadIndexedFileMtimes(): Map<string, number> {
const rows = cachedStmt(
get(),
'SELECT path, modified_at AS modifiedAt FROM indexed_files'
).all() as {
path: string
modifiedAt: number
}[]
const map = new Map<string, number>()
for (const r of rows) map.set(r.path, r.modifiedAt)
return map
}
// Apply an incremental file-index diff ATOMICALLY: delete the gone paths and
// upsert the new/changed records inside ONE transaction. This is the core
// data-loss guard — a crash mid-apply can never leave a partially-wiped index,
// and (unlike the old clear-then-insert) a transient unreadable root only means
// its rows are absent from `toDelete`, so they survive untouched.
export function applyFileIndexDiff(toUpsert: IndexedFileRecord[], toDelete: string[]): void {
const d = get()
const insert = cachedStmt(
d,
`INSERT OR REPLACE INTO indexed_files
(path, filename, extension, file_type, size_bytes, folder, depth, created_at, modified_at, target_path, indexed_at)
VALUES (@path, @filename, @extension, @fileType, @sizeBytes, @folder, @depth, @createdAt, @modifiedAt, @targetPath, @indexedAt)`
)
const del = cachedStmt(d, 'DELETE FROM indexed_files WHERE path = ?')
const indexedAt = Date.now()
const apply = d.transaction(() => {
for (const path of toDelete) del.run(path)
// Default the optional field so better-sqlite3 never sees `undefined`.
for (const r of toUpsert) insert.run({ ...r, targetPath: r.targetPath ?? null, indexedAt })
})
apply()
}
// --- app_meta: durable app-level key/value flags (survives sign-out) ---------
// Kept out of USER_DATA_TABLES so values like the file-index last-run timestamp
// persist across restarts (see the app_meta DDL + dbWipe rationale).
export function getAppMeta(key: string): string | null {
const row = cachedStmt(get(), 'SELECT value FROM app_meta WHERE key = ?').get(key) as
| { value: string | null }
| undefined
return row?.value ?? null
}
export function setAppMeta(key: string, value: string): void {
cachedStmt(get(), 'INSERT OR REPLACE INTO app_meta (key, value) VALUES (?, ?)').run(key, value)
}
// Clear every user-scoped table on sign-out (see dbWipe.ts for scope + rationale).
// wipeUserDataOn lives in the better-sqlite3-free dbWipe.ts so it is unit-testable
// under plain-node vitest, which can't load this module's Electron-ABI native dep.
export function wipeUserData(): void {
wipeUserDataOn(get())