@velvetmonkey/vault-core 2.0.30 → 2.0.31

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 (41) hide show
  1. package/dist/src/entities.d.ts +50 -0
  2. package/dist/src/entities.d.ts.map +1 -0
  3. package/dist/src/entities.js +499 -0
  4. package/dist/src/entities.js.map +1 -0
  5. package/dist/src/index.d.ts +15 -0
  6. package/dist/src/index.d.ts.map +1 -0
  7. package/dist/src/index.js +23 -0
  8. package/dist/src/index.js.map +1 -0
  9. package/dist/src/logging/index.d.ts +7 -0
  10. package/dist/src/logging/index.d.ts.map +1 -0
  11. package/dist/src/logging/index.js +7 -0
  12. package/dist/src/logging/index.js.map +1 -0
  13. package/dist/src/logging/operationLogger.d.ts +59 -0
  14. package/dist/src/logging/operationLogger.d.ts.map +1 -0
  15. package/dist/src/logging/operationLogger.js +282 -0
  16. package/dist/src/logging/operationLogger.js.map +1 -0
  17. package/dist/src/logging/sessionManager.d.ts +35 -0
  18. package/dist/src/logging/sessionManager.d.ts.map +1 -0
  19. package/dist/src/logging/sessionManager.js +68 -0
  20. package/dist/src/logging/sessionManager.js.map +1 -0
  21. package/dist/src/logging/types.d.ts +123 -0
  22. package/dist/src/logging/types.d.ts.map +1 -0
  23. package/dist/src/logging/types.js +23 -0
  24. package/dist/src/logging/types.js.map +1 -0
  25. package/dist/src/protectedZones.d.ts +36 -0
  26. package/dist/src/protectedZones.d.ts.map +1 -0
  27. package/dist/src/protectedZones.js +114 -0
  28. package/dist/src/protectedZones.js.map +1 -0
  29. package/dist/src/sqlite.d.ts +273 -0
  30. package/dist/src/sqlite.d.ts.map +1 -0
  31. package/dist/src/sqlite.js +959 -0
  32. package/dist/src/sqlite.js.map +1 -0
  33. package/dist/src/types.d.ts +171 -0
  34. package/dist/src/types.d.ts.map +1 -0
  35. package/dist/src/types.js +5 -0
  36. package/dist/src/types.js.map +1 -0
  37. package/dist/src/wikilinks.d.ts +76 -0
  38. package/dist/src/wikilinks.d.ts.map +1 -0
  39. package/dist/src/wikilinks.js +681 -0
  40. package/dist/src/wikilinks.js.map +1 -0
  41. package/package.json +2 -2
@@ -0,0 +1,959 @@
1
+ /**
2
+ * Shared SQLite State Management
3
+ *
4
+ * Consolidates scattered JSON files and in-memory state into a single
5
+ * SQLite database with FTS5 for entity search.
6
+ *
7
+ * Target performance:
8
+ * - Startup <100ms for 10k note vault
9
+ * - Entity search <10ms
10
+ * - Single .flywheel/state.db for backup
11
+ */
12
+ import Database from 'better-sqlite3';
13
+ import * as fs from 'fs';
14
+ import * as path from 'path';
15
+ // =============================================================================
16
+ // Constants
17
+ // =============================================================================
18
+ /** Current schema version - bump when schema changes */
19
+ export const SCHEMA_VERSION = 14;
20
+ /** State database filename */
21
+ export const STATE_DB_FILENAME = 'state.db';
22
+ /** Directory for flywheel state */
23
+ export const FLYWHEEL_DIR = '.flywheel';
24
+ // =============================================================================
25
+ // Schema
26
+ // =============================================================================
27
+ const SCHEMA_SQL = `
28
+ -- Schema version tracking
29
+ CREATE TABLE IF NOT EXISTS schema_version (
30
+ version INTEGER PRIMARY KEY,
31
+ applied_at TEXT DEFAULT (datetime('now'))
32
+ );
33
+
34
+ -- Metadata key-value store
35
+ CREATE TABLE IF NOT EXISTS metadata (
36
+ key TEXT PRIMARY KEY,
37
+ value TEXT NOT NULL,
38
+ updated_at TEXT DEFAULT (datetime('now'))
39
+ );
40
+
41
+ -- Entity index (replaces wikilink-entities.json)
42
+ CREATE TABLE IF NOT EXISTS entities (
43
+ id INTEGER PRIMARY KEY,
44
+ name TEXT NOT NULL,
45
+ name_lower TEXT NOT NULL,
46
+ path TEXT NOT NULL,
47
+ category TEXT NOT NULL,
48
+ aliases_json TEXT,
49
+ hub_score INTEGER DEFAULT 0
50
+ );
51
+ CREATE INDEX IF NOT EXISTS idx_entities_name_lower ON entities(name_lower);
52
+ CREATE INDEX IF NOT EXISTS idx_entities_category ON entities(category);
53
+
54
+ -- FTS5 for entity search with porter stemmer
55
+ CREATE VIRTUAL TABLE IF NOT EXISTS entities_fts USING fts5(
56
+ name, aliases, category,
57
+ content='entities', content_rowid='id',
58
+ tokenize='porter unicode61'
59
+ );
60
+
61
+ -- Auto-sync triggers for entities_fts
62
+ CREATE TRIGGER IF NOT EXISTS entities_ai AFTER INSERT ON entities BEGIN
63
+ INSERT INTO entities_fts(rowid, name, aliases, category)
64
+ VALUES (
65
+ new.id,
66
+ new.name,
67
+ COALESCE((SELECT group_concat(value, ' ') FROM json_each(new.aliases_json)), ''),
68
+ new.category
69
+ );
70
+ END;
71
+
72
+ CREATE TRIGGER IF NOT EXISTS entities_ad AFTER DELETE ON entities BEGIN
73
+ INSERT INTO entities_fts(entities_fts, rowid, name, aliases, category)
74
+ VALUES (
75
+ 'delete',
76
+ old.id,
77
+ old.name,
78
+ COALESCE((SELECT group_concat(value, ' ') FROM json_each(old.aliases_json)), ''),
79
+ old.category
80
+ );
81
+ END;
82
+
83
+ CREATE TRIGGER IF NOT EXISTS entities_au AFTER UPDATE ON entities BEGIN
84
+ INSERT INTO entities_fts(entities_fts, rowid, name, aliases, category)
85
+ VALUES (
86
+ 'delete',
87
+ old.id,
88
+ old.name,
89
+ COALESCE((SELECT group_concat(value, ' ') FROM json_each(old.aliases_json)), ''),
90
+ old.category
91
+ );
92
+ INSERT INTO entities_fts(rowid, name, aliases, category)
93
+ VALUES (
94
+ new.id,
95
+ new.name,
96
+ COALESCE((SELECT group_concat(value, ' ') FROM json_each(new.aliases_json)), ''),
97
+ new.category
98
+ );
99
+ END;
100
+
101
+ -- Recency tracking (replaces entity-recency.json)
102
+ CREATE TABLE IF NOT EXISTS recency (
103
+ entity_name_lower TEXT PRIMARY KEY,
104
+ last_mentioned_at INTEGER NOT NULL,
105
+ mention_count INTEGER DEFAULT 1
106
+ );
107
+
108
+ -- Write state (replaces last-commit.json and other write state)
109
+ CREATE TABLE IF NOT EXISTS write_state (
110
+ key TEXT PRIMARY KEY,
111
+ value TEXT NOT NULL,
112
+ updated_at TEXT DEFAULT (datetime('now'))
113
+ );
114
+
115
+ -- Content search FTS5 (migrated from vault-search.db)
116
+ -- v11: Added frontmatter column for weighted search (path, title, frontmatter, content)
117
+ CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
118
+ path, title, frontmatter, content,
119
+ tokenize='porter'
120
+ );
121
+
122
+ -- FTS5 build metadata (consolidated from vault-search.db)
123
+ CREATE TABLE IF NOT EXISTS fts_metadata (
124
+ key TEXT PRIMARY KEY,
125
+ value TEXT
126
+ );
127
+
128
+ -- Vault index cache (for fast startup)
129
+ -- Stores serialized VaultIndex to avoid full rebuild on startup
130
+ CREATE TABLE IF NOT EXISTS vault_index_cache (
131
+ id INTEGER PRIMARY KEY CHECK (id = 1),
132
+ data BLOB NOT NULL,
133
+ built_at INTEGER NOT NULL,
134
+ note_count INTEGER NOT NULL,
135
+ version INTEGER DEFAULT 1
136
+ );
137
+
138
+ -- Flywheel configuration (replaces .flywheel.json)
139
+ CREATE TABLE IF NOT EXISTS flywheel_config (
140
+ key TEXT PRIMARY KEY,
141
+ value TEXT NOT NULL,
142
+ updated_at TEXT DEFAULT (datetime('now'))
143
+ );
144
+
145
+ -- Vault metrics (v4: growth tracking)
146
+ CREATE TABLE IF NOT EXISTS vault_metrics (
147
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
148
+ timestamp INTEGER NOT NULL,
149
+ metric TEXT NOT NULL,
150
+ value REAL NOT NULL
151
+ );
152
+ CREATE INDEX IF NOT EXISTS idx_vault_metrics_ts ON vault_metrics(timestamp);
153
+ CREATE INDEX IF NOT EXISTS idx_vault_metrics_m ON vault_metrics(metric, timestamp);
154
+
155
+ -- Wikilink feedback (v4: quality tracking)
156
+ CREATE TABLE IF NOT EXISTS wikilink_feedback (
157
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
158
+ entity TEXT NOT NULL,
159
+ context TEXT NOT NULL,
160
+ note_path TEXT NOT NULL,
161
+ correct INTEGER NOT NULL,
162
+ created_at TEXT DEFAULT (datetime('now'))
163
+ );
164
+ CREATE INDEX IF NOT EXISTS idx_wl_feedback_entity ON wikilink_feedback(entity);
165
+
166
+ -- Wikilink suppressions (v4: auto-suppress false positives)
167
+ CREATE TABLE IF NOT EXISTS wikilink_suppressions (
168
+ entity TEXT PRIMARY KEY,
169
+ false_positive_rate REAL NOT NULL,
170
+ updated_at TEXT DEFAULT (datetime('now'))
171
+ );
172
+
173
+ -- Wikilink applications tracking (v5: implicit feedback)
174
+ CREATE TABLE IF NOT EXISTS wikilink_applications (
175
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
176
+ entity TEXT NOT NULL,
177
+ note_path TEXT NOT NULL,
178
+ applied_at TEXT DEFAULT (datetime('now')),
179
+ status TEXT DEFAULT 'applied'
180
+ );
181
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_wl_apps_unique ON wikilink_applications(entity, note_path);
182
+
183
+ -- Index events tracking (v6: index activity history)
184
+ CREATE TABLE IF NOT EXISTS index_events (
185
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
186
+ timestamp INTEGER NOT NULL,
187
+ trigger TEXT NOT NULL,
188
+ duration_ms INTEGER NOT NULL,
189
+ success INTEGER NOT NULL DEFAULT 1,
190
+ note_count INTEGER,
191
+ files_changed INTEGER,
192
+ changed_paths TEXT,
193
+ error TEXT,
194
+ steps TEXT
195
+ );
196
+ CREATE INDEX IF NOT EXISTS idx_index_events_ts ON index_events(timestamp);
197
+
198
+ -- Tool invocation tracking (v7: usage analytics)
199
+ CREATE TABLE IF NOT EXISTS tool_invocations (
200
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
201
+ timestamp INTEGER NOT NULL,
202
+ tool_name TEXT NOT NULL,
203
+ session_id TEXT,
204
+ note_paths TEXT,
205
+ duration_ms INTEGER,
206
+ success INTEGER NOT NULL DEFAULT 1
207
+ );
208
+ CREATE INDEX IF NOT EXISTS idx_tool_inv_ts ON tool_invocations(timestamp);
209
+ CREATE INDEX IF NOT EXISTS idx_tool_inv_tool ON tool_invocations(tool_name, timestamp);
210
+ CREATE INDEX IF NOT EXISTS idx_tool_inv_session ON tool_invocations(session_id, timestamp);
211
+
212
+ -- Graph topology snapshots (v8: structural evolution)
213
+ CREATE TABLE IF NOT EXISTS graph_snapshots (
214
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
215
+ timestamp INTEGER NOT NULL,
216
+ metric TEXT NOT NULL,
217
+ value REAL NOT NULL,
218
+ details TEXT
219
+ );
220
+ CREATE INDEX IF NOT EXISTS idx_graph_snap_ts ON graph_snapshots(timestamp);
221
+ CREATE INDEX IF NOT EXISTS idx_graph_snap_m ON graph_snapshots(metric, timestamp);
222
+
223
+ -- Note embeddings for semantic search (v9)
224
+ CREATE TABLE IF NOT EXISTS note_embeddings (
225
+ path TEXT PRIMARY KEY,
226
+ embedding BLOB NOT NULL,
227
+ content_hash TEXT NOT NULL,
228
+ model TEXT NOT NULL,
229
+ updated_at INTEGER NOT NULL
230
+ );
231
+
232
+ -- Entity embeddings for semantic entity search (v10)
233
+ CREATE TABLE IF NOT EXISTS entity_embeddings (
234
+ entity_name TEXT PRIMARY KEY,
235
+ embedding BLOB NOT NULL,
236
+ source_hash TEXT NOT NULL,
237
+ model TEXT NOT NULL,
238
+ updated_at INTEGER NOT NULL
239
+ );
240
+
241
+ -- Task cache for fast task queries (v12)
242
+ CREATE TABLE IF NOT EXISTS tasks (
243
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
244
+ path TEXT NOT NULL,
245
+ line INTEGER NOT NULL,
246
+ text TEXT NOT NULL,
247
+ status TEXT NOT NULL,
248
+ raw TEXT NOT NULL,
249
+ context TEXT,
250
+ tags_json TEXT,
251
+ due_date TEXT,
252
+ UNIQUE(path, line)
253
+ );
254
+ CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
255
+ CREATE INDEX IF NOT EXISTS idx_tasks_path ON tasks(path);
256
+ CREATE INDEX IF NOT EXISTS idx_tasks_due ON tasks(due_date);
257
+
258
+ -- Merge dismissals (v13: persistent merge pair suppression)
259
+ CREATE TABLE IF NOT EXISTS merge_dismissals (
260
+ pair_key TEXT PRIMARY KEY,
261
+ source_path TEXT NOT NULL,
262
+ target_path TEXT NOT NULL,
263
+ source_name TEXT NOT NULL,
264
+ target_name TEXT NOT NULL,
265
+ reason TEXT NOT NULL,
266
+ dismissed_at TEXT DEFAULT (datetime('now'))
267
+ );
268
+ `;
269
+ // =============================================================================
270
+ // Database Initialization
271
+ // =============================================================================
272
+ /**
273
+ * Get the database path for a vault
274
+ */
275
+ export function getStateDbPath(vaultPath) {
276
+ const flywheelDir = path.join(vaultPath, FLYWHEEL_DIR);
277
+ if (!fs.existsSync(flywheelDir)) {
278
+ fs.mkdirSync(flywheelDir, { recursive: true });
279
+ }
280
+ return path.join(flywheelDir, STATE_DB_FILENAME);
281
+ }
282
+ /**
283
+ * Initialize schema and run migrations
284
+ */
285
+ function initSchema(db) {
286
+ // Enable WAL mode for better concurrent read performance
287
+ db.pragma('journal_mode = WAL');
288
+ // Enable foreign keys
289
+ db.pragma('foreign_keys = ON');
290
+ // Run schema creation
291
+ db.exec(SCHEMA_SQL);
292
+ // Guard: Verify critical tables were created
293
+ // This catches cases where schema execution silently failed (e.g., corrupted db)
294
+ const tables = db.prepare(`
295
+ SELECT name FROM sqlite_master
296
+ WHERE type='table' AND name IN ('entities', 'schema_version', 'metadata')
297
+ `).all();
298
+ if (tables.length < 3) {
299
+ const foundTables = tables.map(t => t.name).join(', ') || 'none';
300
+ throw new Error(`[vault-core] Schema validation failed: expected 3 critical tables, found ${tables.length} (${foundTables}). ` +
301
+ `Database may be corrupted. Delete ${db.name} and restart.`);
302
+ }
303
+ // Check and record schema version
304
+ const versionRow = db.prepare('SELECT MAX(version) as version FROM schema_version').get();
305
+ const currentVersion = versionRow?.version ?? 0;
306
+ if (currentVersion < SCHEMA_VERSION) {
307
+ // v2: Drop dead notes/links tables if they exist from v1
308
+ if (currentVersion < 2) {
309
+ db.exec('DROP TABLE IF EXISTS notes');
310
+ db.exec('DROP TABLE IF EXISTS links');
311
+ }
312
+ // v3: Rename crank_state → write_state
313
+ if (currentVersion < 3) {
314
+ const hasCrankState = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='crank_state'`).get();
315
+ const hasWriteState = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='write_state'`).get();
316
+ if (hasCrankState && !hasWriteState) {
317
+ db.exec('ALTER TABLE crank_state RENAME TO write_state');
318
+ }
319
+ else if (hasCrankState && hasWriteState) {
320
+ // Both exist (stale db) — drop the old one
321
+ db.exec('DROP TABLE crank_state');
322
+ }
323
+ }
324
+ // v4: vault_metrics, wikilink_feedback, wikilink_suppressions tables
325
+ // (created by SCHEMA_SQL above via CREATE TABLE IF NOT EXISTS)
326
+ // v5: wikilink_applications table (implicit feedback tracking)
327
+ // (created by SCHEMA_SQL above via CREATE TABLE IF NOT EXISTS)
328
+ // v6: index_events table (index activity history)
329
+ // (created by SCHEMA_SQL above via CREATE TABLE IF NOT EXISTS)
330
+ // v7: tool_invocations table (usage analytics)
331
+ // (created by SCHEMA_SQL above via CREATE TABLE IF NOT EXISTS)
332
+ // v8: graph_snapshots table (structural evolution)
333
+ // (created by SCHEMA_SQL above via CREATE TABLE IF NOT EXISTS)
334
+ // v9: note_embeddings table (semantic search)
335
+ // (created by SCHEMA_SQL above via CREATE TABLE IF NOT EXISTS)
336
+ // v10: entity_embeddings table (semantic entity search)
337
+ // (created by SCHEMA_SQL above via CREATE TABLE IF NOT EXISTS)
338
+ // v11: notes_fts gains frontmatter column (4-col: path, title, frontmatter, content)
339
+ // Virtual tables can't ALTER, so drop and recreate
340
+ if (currentVersion < 11) {
341
+ db.exec('DROP TABLE IF EXISTS notes_fts');
342
+ db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
343
+ path, title, frontmatter, content,
344
+ tokenize='porter'
345
+ )`);
346
+ // Clear FTS metadata to force rebuild with new schema
347
+ db.exec(`DELETE FROM fts_metadata WHERE key = 'last_built'`);
348
+ }
349
+ // v12: tasks cache table (created by SCHEMA_SQL above via CREATE TABLE IF NOT EXISTS)
350
+ // v13: merge_dismissals table (created by SCHEMA_SQL above via CREATE TABLE IF NOT EXISTS)
351
+ // v14: Add steps column to index_events (pipeline observability)
352
+ if (currentVersion < 14) {
353
+ const hasSteps = db.prepare(`SELECT COUNT(*) as cnt FROM pragma_table_info('index_events') WHERE name = 'steps'`).get();
354
+ if (hasSteps.cnt === 0) {
355
+ db.exec('ALTER TABLE index_events ADD COLUMN steps TEXT');
356
+ }
357
+ }
358
+ db.prepare('INSERT OR IGNORE INTO schema_version (version) VALUES (?)').run(SCHEMA_VERSION);
359
+ }
360
+ }
361
+ /**
362
+ * Open or create the state database for a vault
363
+ *
364
+ * @param vaultPath - Absolute path to the vault root
365
+ * @returns StateDb instance with prepared statements
366
+ */
367
+ export function openStateDb(vaultPath) {
368
+ const dbPath = getStateDbPath(vaultPath);
369
+ // Guard: Delete corrupted 0-byte database files
370
+ // This can happen when better-sqlite3 fails to compile (e.g., Node 24)
371
+ // and creates an empty file instead of a valid SQLite database
372
+ if (fs.existsSync(dbPath)) {
373
+ const stat = fs.statSync(dbPath);
374
+ if (stat.size === 0) {
375
+ console.error(`[vault-core] Deleting corrupted 0-byte state.db at ${dbPath}`);
376
+ fs.unlinkSync(dbPath);
377
+ // Also remove WAL and SHM files if they exist
378
+ const walPath = dbPath + '-wal';
379
+ const shmPath = dbPath + '-shm';
380
+ if (fs.existsSync(walPath))
381
+ fs.unlinkSync(walPath);
382
+ if (fs.existsSync(shmPath))
383
+ fs.unlinkSync(shmPath);
384
+ }
385
+ }
386
+ const db = new Database(dbPath);
387
+ // Initialize schema
388
+ initSchema(db);
389
+ // Prepare all statements
390
+ const stateDb = {
391
+ db,
392
+ vaultPath,
393
+ dbPath,
394
+ // Entity operations
395
+ insertEntity: db.prepare(`
396
+ INSERT INTO entities (name, name_lower, path, category, aliases_json, hub_score)
397
+ VALUES (?, ?, ?, ?, ?, ?)
398
+ `),
399
+ updateEntity: db.prepare(`
400
+ UPDATE entities
401
+ SET name = ?, name_lower = ?, path = ?, category = ?, aliases_json = ?, hub_score = ?
402
+ WHERE id = ?
403
+ `),
404
+ deleteEntity: db.prepare('DELETE FROM entities WHERE id = ?'),
405
+ getEntityByName: db.prepare(`
406
+ SELECT id, name, name_lower, path, category, aliases_json, hub_score
407
+ FROM entities WHERE name_lower = ?
408
+ `),
409
+ getEntityById: db.prepare(`
410
+ SELECT id, name, name_lower, path, category, aliases_json, hub_score
411
+ FROM entities WHERE id = ?
412
+ `),
413
+ getAllEntities: db.prepare(`
414
+ SELECT id, name, name_lower, path, category, aliases_json, hub_score
415
+ FROM entities ORDER BY name
416
+ `),
417
+ getEntitiesByCategory: db.prepare(`
418
+ SELECT id, name, name_lower, path, category, aliases_json, hub_score
419
+ FROM entities WHERE category = ? ORDER BY name
420
+ `),
421
+ searchEntitiesFts: db.prepare(`
422
+ SELECT e.id, e.name, e.name_lower, e.path, e.category, e.aliases_json, e.hub_score,
423
+ bm25(entities_fts) as rank
424
+ FROM entities_fts
425
+ JOIN entities e ON e.id = entities_fts.rowid
426
+ WHERE entities_fts MATCH ?
427
+ ORDER BY rank
428
+ LIMIT ?
429
+ `),
430
+ clearEntities: db.prepare('DELETE FROM entities'),
431
+ // Entity alias lookup
432
+ getEntitiesByAlias: db.prepare(`
433
+ SELECT e.id, e.name, e.name_lower, e.path, e.category, e.aliases_json, e.hub_score
434
+ FROM entities e
435
+ WHERE EXISTS (SELECT 1 FROM json_each(e.aliases_json) WHERE LOWER(value) = ?)
436
+ `),
437
+ // Recency operations
438
+ upsertRecency: db.prepare(`
439
+ INSERT INTO recency (entity_name_lower, last_mentioned_at, mention_count)
440
+ VALUES (?, ?, 1)
441
+ ON CONFLICT(entity_name_lower) DO UPDATE SET
442
+ last_mentioned_at = excluded.last_mentioned_at,
443
+ mention_count = mention_count + 1
444
+ `),
445
+ getRecency: db.prepare(`
446
+ SELECT entity_name_lower, last_mentioned_at, mention_count
447
+ FROM recency WHERE entity_name_lower = ?
448
+ `),
449
+ getAllRecency: db.prepare(`
450
+ SELECT entity_name_lower, last_mentioned_at, mention_count
451
+ FROM recency ORDER BY last_mentioned_at DESC
452
+ `),
453
+ clearRecency: db.prepare('DELETE FROM recency'),
454
+ // Write state operations
455
+ setWriteState: db.prepare(`
456
+ INSERT INTO write_state (key, value, updated_at)
457
+ VALUES (?, ?, datetime('now'))
458
+ ON CONFLICT(key) DO UPDATE SET
459
+ value = excluded.value,
460
+ updated_at = datetime('now')
461
+ `),
462
+ getWriteState: db.prepare('SELECT value FROM write_state WHERE key = ?'),
463
+ deleteWriteState: db.prepare('DELETE FROM write_state WHERE key = ?'),
464
+ // Flywheel config operations
465
+ setFlywheelConfigStmt: db.prepare(`
466
+ INSERT INTO flywheel_config (key, value, updated_at)
467
+ VALUES (?, ?, datetime('now'))
468
+ ON CONFLICT(key) DO UPDATE SET
469
+ value = excluded.value,
470
+ updated_at = datetime('now')
471
+ `),
472
+ getFlywheelConfigStmt: db.prepare('SELECT value FROM flywheel_config WHERE key = ?'),
473
+ getAllFlywheelConfigStmt: db.prepare('SELECT key, value FROM flywheel_config'),
474
+ deleteFlywheelConfigStmt: db.prepare('DELETE FROM flywheel_config WHERE key = ?'),
475
+ // Task cache operations
476
+ insertTask: db.prepare(`
477
+ INSERT OR REPLACE INTO tasks (path, line, text, status, raw, context, tags_json, due_date)
478
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
479
+ `),
480
+ deleteTasksForPath: db.prepare('DELETE FROM tasks WHERE path = ?'),
481
+ clearAllTasks: db.prepare('DELETE FROM tasks'),
482
+ countTasksByStatus: db.prepare('SELECT status, COUNT(*) as cnt FROM tasks GROUP BY status'),
483
+ // Metadata operations
484
+ getMetadataValue: db.prepare('SELECT value FROM metadata WHERE key = ?'),
485
+ setMetadataValue: db.prepare(`
486
+ INSERT INTO metadata (key, value, updated_at)
487
+ VALUES (?, ?, datetime('now'))
488
+ ON CONFLICT(key) DO UPDATE SET
489
+ value = excluded.value,
490
+ updated_at = datetime('now')
491
+ `),
492
+ // Transactions
493
+ bulkInsertEntities: db.transaction((entities, category) => {
494
+ let count = 0;
495
+ for (const entity of entities) {
496
+ stateDb.insertEntity.run(entity.name, entity.name.toLowerCase(), entity.path, category, JSON.stringify(entity.aliases), entity.hubScore ?? 0);
497
+ count++;
498
+ }
499
+ return count;
500
+ }),
501
+ replaceAllEntities: db.transaction((index) => {
502
+ // Clear existing entities
503
+ stateDb.clearEntities.run();
504
+ // Insert all entities by category
505
+ const categories = [
506
+ 'technologies', 'acronyms', 'people', 'projects',
507
+ 'organizations', 'locations', 'concepts', 'animals',
508
+ 'media', 'events', 'documents', 'vehicles', 'health',
509
+ 'finance', 'food', 'hobbies', 'other',
510
+ ];
511
+ let total = 0;
512
+ for (const category of categories) {
513
+ const entities = index[category];
514
+ if (!entities?.length)
515
+ continue;
516
+ for (const entity of entities) {
517
+ // Handle both string and EntityWithAliases formats
518
+ const entityObj = typeof entity === 'string'
519
+ ? { name: entity, path: '', aliases: [], hubScore: 0 }
520
+ : entity;
521
+ stateDb.insertEntity.run(entityObj.name, entityObj.name.toLowerCase(), entityObj.path, category, JSON.stringify(entityObj.aliases), entityObj.hubScore ?? 0);
522
+ total++;
523
+ }
524
+ }
525
+ // Update metadata
526
+ stateDb.setMetadataValue.run('entities_built_at', new Date().toISOString());
527
+ stateDb.setMetadataValue.run('entity_count', String(total));
528
+ return total;
529
+ }),
530
+ close: () => {
531
+ db.close();
532
+ },
533
+ };
534
+ return stateDb;
535
+ }
536
+ // =============================================================================
537
+ // Entity Operations
538
+ // =============================================================================
539
+ /**
540
+ * Search entities using FTS5 with porter stemming
541
+ *
542
+ * @param stateDb - State database instance
543
+ * @param query - Search query (supports FTS5 syntax)
544
+ * @param limit - Maximum results to return
545
+ * @returns Array of matching entities with relevance scores
546
+ */
547
+ export function searchEntities(stateDb, query, limit = 20) {
548
+ const escapedQuery = escapeFts5Query(query);
549
+ // Handle empty query - return empty results
550
+ if (!escapedQuery) {
551
+ return [];
552
+ }
553
+ const rows = stateDb.searchEntitiesFts.all(escapedQuery, limit);
554
+ return rows.map(row => ({
555
+ id: row.id,
556
+ name: row.name,
557
+ nameLower: row.name_lower,
558
+ path: row.path,
559
+ category: row.category,
560
+ aliases: row.aliases_json ? JSON.parse(row.aliases_json) : [],
561
+ hubScore: row.hub_score,
562
+ rank: row.rank,
563
+ }));
564
+ }
565
+ /**
566
+ * Search entities by prefix for autocomplete
567
+ *
568
+ * @param stateDb - State database instance
569
+ * @param prefix - Prefix to search for
570
+ * @param limit - Maximum results to return
571
+ */
572
+ export function searchEntitiesPrefix(stateDb, prefix, limit = 20) {
573
+ return searchEntities(stateDb, `${escapeFts5Query(prefix)}*`, limit);
574
+ }
575
+ /**
576
+ * Get entity by exact name (case-insensitive)
577
+ */
578
+ export function getEntityByName(stateDb, name) {
579
+ const row = stateDb.getEntityByName.get(name.toLowerCase());
580
+ if (!row)
581
+ return null;
582
+ return {
583
+ id: row.id,
584
+ name: row.name,
585
+ nameLower: row.name_lower,
586
+ path: row.path,
587
+ category: row.category,
588
+ aliases: row.aliases_json ? JSON.parse(row.aliases_json) : [],
589
+ hubScore: row.hub_score,
590
+ rank: 0,
591
+ };
592
+ }
593
+ /**
594
+ * Get all entities from the database
595
+ */
596
+ export function getAllEntitiesFromDb(stateDb) {
597
+ const rows = stateDb.getAllEntities.all();
598
+ return rows.map(row => ({
599
+ id: row.id,
600
+ name: row.name,
601
+ nameLower: row.name_lower,
602
+ path: row.path,
603
+ category: row.category,
604
+ aliases: row.aliases_json ? JSON.parse(row.aliases_json) : [],
605
+ hubScore: row.hub_score,
606
+ rank: 0,
607
+ }));
608
+ }
609
+ /**
610
+ * Convert database entities back to EntityIndex format
611
+ */
612
+ export function getEntityIndexFromDb(stateDb) {
613
+ const entities = getAllEntitiesFromDb(stateDb);
614
+ const index = {
615
+ technologies: [],
616
+ acronyms: [],
617
+ people: [],
618
+ projects: [],
619
+ organizations: [],
620
+ locations: [],
621
+ concepts: [],
622
+ animals: [],
623
+ media: [],
624
+ events: [],
625
+ documents: [],
626
+ vehicles: [],
627
+ health: [],
628
+ finance: [],
629
+ food: [],
630
+ hobbies: [],
631
+ other: [],
632
+ _metadata: {
633
+ total_entities: entities.length,
634
+ generated_at: new Date().toISOString(),
635
+ vault_path: stateDb.vaultPath,
636
+ source: 'vault-core sqlite',
637
+ },
638
+ };
639
+ for (const entity of entities) {
640
+ const entityObj = {
641
+ name: entity.name,
642
+ path: entity.path,
643
+ aliases: entity.aliases,
644
+ hubScore: entity.hubScore,
645
+ };
646
+ index[entity.category].push(entityObj);
647
+ }
648
+ return index;
649
+ }
650
+ /**
651
+ * Get entities that have a given alias (case-insensitive)
652
+ *
653
+ * @param stateDb - State database instance
654
+ * @param alias - Alias to search for (case-insensitive)
655
+ * @returns Array of matching entities
656
+ */
657
+ export function getEntitiesByAlias(stateDb, alias) {
658
+ const rows = stateDb.getEntitiesByAlias.all(alias.toLowerCase());
659
+ return rows.map(row => ({
660
+ id: row.id,
661
+ name: row.name,
662
+ nameLower: row.name_lower,
663
+ path: row.path,
664
+ category: row.category,
665
+ aliases: row.aliases_json ? JSON.parse(row.aliases_json) : [],
666
+ hubScore: row.hub_score,
667
+ rank: 0,
668
+ }));
669
+ }
670
+ // =============================================================================
671
+ // Recency Operations
672
+ // =============================================================================
673
+ /**
674
+ * Record a mention of an entity
675
+ */
676
+ export function recordEntityMention(stateDb, entityName, mentionedAt = new Date()) {
677
+ stateDb.upsertRecency.run(entityName.toLowerCase(), mentionedAt.getTime());
678
+ }
679
+ /**
680
+ * Get recency info for an entity
681
+ */
682
+ export function getEntityRecency(stateDb, entityName) {
683
+ const row = stateDb.getRecency.get(entityName.toLowerCase());
684
+ if (!row)
685
+ return null;
686
+ return {
687
+ entityNameLower: row.entity_name_lower,
688
+ lastMentionedAt: row.last_mentioned_at,
689
+ mentionCount: row.mention_count,
690
+ };
691
+ }
692
+ /**
693
+ * Get all recency data ordered by most recent
694
+ */
695
+ export function getAllRecency(stateDb) {
696
+ const rows = stateDb.getAllRecency.all();
697
+ return rows.map(row => ({
698
+ entityNameLower: row.entity_name_lower,
699
+ lastMentionedAt: row.last_mentioned_at,
700
+ mentionCount: row.mention_count,
701
+ }));
702
+ }
703
+ // =============================================================================
704
+ // Write State Operations
705
+ // =============================================================================
706
+ /**
707
+ * Set a write state value
708
+ */
709
+ export function setWriteState(stateDb, key, value) {
710
+ stateDb.setWriteState.run(key, JSON.stringify(value));
711
+ }
712
+ /**
713
+ * Get a write state value
714
+ */
715
+ export function getWriteState(stateDb, key) {
716
+ const row = stateDb.getWriteState.get(key);
717
+ if (!row)
718
+ return null;
719
+ return JSON.parse(row.value);
720
+ }
721
+ /**
722
+ * Delete a write state key
723
+ */
724
+ export function deleteWriteState(stateDb, key) {
725
+ stateDb.deleteWriteState.run(key);
726
+ }
727
+ /**
728
+ * Set a flywheel config value
729
+ */
730
+ export function setFlywheelConfig(stateDb, key, value) {
731
+ stateDb.setFlywheelConfigStmt.run(key, JSON.stringify(value));
732
+ }
733
+ /**
734
+ * Get a flywheel config value
735
+ */
736
+ export function getFlywheelConfig(stateDb, key) {
737
+ const row = stateDb.getFlywheelConfigStmt.get(key);
738
+ if (!row)
739
+ return null;
740
+ return JSON.parse(row.value);
741
+ }
742
+ /**
743
+ * Get all flywheel config values as an object
744
+ */
745
+ export function getAllFlywheelConfig(stateDb) {
746
+ const rows = stateDb.getAllFlywheelConfigStmt.all();
747
+ const config = {};
748
+ for (const row of rows) {
749
+ try {
750
+ config[row.key] = JSON.parse(row.value);
751
+ }
752
+ catch {
753
+ config[row.key] = row.value;
754
+ }
755
+ }
756
+ return config;
757
+ }
758
+ /**
759
+ * Delete a flywheel config key
760
+ */
761
+ export function deleteFlywheelConfig(stateDb, key) {
762
+ stateDb.deleteFlywheelConfigStmt.run(key);
763
+ }
764
+ /**
765
+ * Save entire Flywheel config object to database
766
+ * Stores each top-level key as a separate row
767
+ */
768
+ export function saveFlywheelConfigToDb(stateDb, config) {
769
+ const transaction = stateDb.db.transaction(() => {
770
+ for (const [key, value] of Object.entries(config)) {
771
+ if (value !== undefined) {
772
+ setFlywheelConfig(stateDb, key, value);
773
+ }
774
+ }
775
+ });
776
+ transaction();
777
+ }
778
+ /**
779
+ * Load Flywheel config from database and reconstruct as typed object
780
+ */
781
+ export function loadFlywheelConfigFromDb(stateDb) {
782
+ const config = getAllFlywheelConfig(stateDb);
783
+ if (Object.keys(config).length === 0) {
784
+ return null;
785
+ }
786
+ return config;
787
+ }
788
+ // =============================================================================
789
+ // Merge Dismissal Operations
790
+ // =============================================================================
791
+ /**
792
+ * Record a merge dismissal so the pair never reappears in suggestions.
793
+ */
794
+ export function recordMergeDismissal(db, sourcePath, targetPath, sourceName, targetName, reason) {
795
+ const pairKey = [sourcePath, targetPath].sort().join('::');
796
+ db.db.prepare(`INSERT OR IGNORE INTO merge_dismissals
797
+ (pair_key, source_path, target_path, source_name, target_name, reason)
798
+ VALUES (?, ?, ?, ?, ?, ?)`)
799
+ .run(pairKey, sourcePath, targetPath, sourceName, targetName, reason);
800
+ }
801
+ /**
802
+ * Get all dismissed merge pair keys for filtering.
803
+ */
804
+ export function getDismissedMergePairs(db) {
805
+ const rows = db.db.prepare('SELECT pair_key FROM merge_dismissals').all();
806
+ return new Set(rows.map(r => r.pair_key));
807
+ }
808
+ // =============================================================================
809
+ // Metadata Operations
810
+ // =============================================================================
811
+ /**
812
+ * Get database metadata
813
+ */
814
+ export function getStateDbMetadata(stateDb) {
815
+ const schemaRow = stateDb.db.prepare('SELECT MAX(version) as version FROM schema_version').get();
816
+ const entitiesBuiltRow = stateDb.getMetadataValue.get('entities_built_at');
817
+ const entityCountRow = stateDb.getMetadataValue.get('entity_count');
818
+ const notesBuiltRow = stateDb.getMetadataValue.get('notes_built_at');
819
+ const noteCountRow = stateDb.getMetadataValue.get('note_count');
820
+ return {
821
+ schemaVersion: schemaRow?.version ?? 0,
822
+ entitiesBuiltAt: entitiesBuiltRow?.value ?? null,
823
+ entityCount: entityCountRow ? parseInt(entityCountRow.value, 10) : 0,
824
+ notesBuiltAt: notesBuiltRow?.value ?? null,
825
+ noteCount: noteCountRow ? parseInt(noteCountRow.value, 10) : 0,
826
+ };
827
+ }
828
+ /**
829
+ * Check if entity data is stale (older than threshold)
830
+ */
831
+ export function isEntityDataStale(stateDb, thresholdMs = 60 * 60 * 1000 // 1 hour default
832
+ ) {
833
+ const metadata = getStateDbMetadata(stateDb);
834
+ if (!metadata.entitiesBuiltAt) {
835
+ return true;
836
+ }
837
+ const builtAt = new Date(metadata.entitiesBuiltAt).getTime();
838
+ const age = Date.now() - builtAt;
839
+ return age > thresholdMs;
840
+ }
841
+ // =============================================================================
842
+ // Utility Functions
843
+ // =============================================================================
844
+ /**
845
+ * Escape special FTS5 characters in a query
846
+ */
847
+ export function escapeFts5Query(query) {
848
+ // Handle empty query
849
+ if (!query || !query.trim()) {
850
+ return '';
851
+ }
852
+ // Remove or escape FTS5 special characters
853
+ // Keep * for prefix matching, escape others
854
+ return query
855
+ .replace(/"/g, '""') // Escape quotes
856
+ .replace(/[(){}[\]^~:-]/g, ' ') // Remove special operators including hyphen
857
+ .replace(/\s+/g, ' ') // Normalize whitespace
858
+ .trim();
859
+ }
860
+ /**
861
+ * Check if the state database exists for a vault
862
+ */
863
+ export function stateDbExists(vaultPath) {
864
+ const dbPath = getStateDbPath(vaultPath);
865
+ return fs.existsSync(dbPath);
866
+ }
867
+ /**
868
+ * Delete the state database (for testing or reset)
869
+ */
870
+ export function deleteStateDb(vaultPath) {
871
+ const dbPath = getStateDbPath(vaultPath);
872
+ if (fs.existsSync(dbPath)) {
873
+ fs.unlinkSync(dbPath);
874
+ }
875
+ // Also remove WAL and SHM files if they exist
876
+ const walPath = dbPath + '-wal';
877
+ const shmPath = dbPath + '-shm';
878
+ if (fs.existsSync(walPath))
879
+ fs.unlinkSync(walPath);
880
+ if (fs.existsSync(shmPath))
881
+ fs.unlinkSync(shmPath);
882
+ }
883
+ /**
884
+ * Save VaultIndex to cache
885
+ *
886
+ * @param stateDb - State database instance
887
+ * @param indexData - Serialized VaultIndex data
888
+ */
889
+ export function saveVaultIndexCache(stateDb, indexData) {
890
+ const data = JSON.stringify(indexData);
891
+ const stmt = stateDb.db.prepare(`
892
+ INSERT OR REPLACE INTO vault_index_cache (id, data, built_at, note_count, version)
893
+ VALUES (1, ?, ?, ?, 1)
894
+ `);
895
+ stmt.run(data, indexData.builtAt, indexData.notes.length);
896
+ }
897
+ /**
898
+ * Load VaultIndex from cache
899
+ *
900
+ * @param stateDb - State database instance
901
+ * @returns Cached VaultIndex data or null if not found
902
+ */
903
+ export function loadVaultIndexCache(stateDb) {
904
+ const stmt = stateDb.db.prepare(`
905
+ SELECT data, built_at, note_count FROM vault_index_cache WHERE id = 1
906
+ `);
907
+ const row = stmt.get();
908
+ if (!row)
909
+ return null;
910
+ try {
911
+ return JSON.parse(row.data);
912
+ }
913
+ catch {
914
+ return null;
915
+ }
916
+ }
917
+ /**
918
+ * Get cache metadata without loading full data
919
+ */
920
+ export function getVaultIndexCacheInfo(stateDb) {
921
+ const stmt = stateDb.db.prepare(`
922
+ SELECT built_at, note_count, version FROM vault_index_cache WHERE id = 1
923
+ `);
924
+ const row = stmt.get();
925
+ if (!row)
926
+ return null;
927
+ return {
928
+ builtAt: new Date(row.built_at),
929
+ noteCount: row.note_count,
930
+ version: row.version,
931
+ };
932
+ }
933
+ /**
934
+ * Clear the vault index cache
935
+ */
936
+ export function clearVaultIndexCache(stateDb) {
937
+ stateDb.db.prepare('DELETE FROM vault_index_cache').run();
938
+ }
939
+ /**
940
+ * Check if cache is valid (not too old and note count matches)
941
+ *
942
+ * @param stateDb - State database instance
943
+ * @param actualNoteCount - Current number of notes in vault
944
+ * @param maxAgeMs - Maximum cache age in milliseconds (default 24 hours)
945
+ */
946
+ export function isVaultIndexCacheValid(stateDb, actualNoteCount, maxAgeMs = 24 * 60 * 60 * 1000) {
947
+ const info = getVaultIndexCacheInfo(stateDb);
948
+ if (!info)
949
+ return false;
950
+ // Check note count matches (quick validation)
951
+ if (info.noteCount !== actualNoteCount)
952
+ return false;
953
+ // Check age
954
+ const age = Date.now() - info.builtAt.getTime();
955
+ if (age > maxAgeMs)
956
+ return false;
957
+ return true;
958
+ }
959
+ //# sourceMappingURL=sqlite.js.map