@velvetmonkey/vault-core 2.7.0 → 2.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -16,6 +16,6 @@ export { parseMarkdown } from './parseMarkdown.js';
16
16
  export { getProtectedZonesFromAst } from './astProtectedZones.js';
17
17
  export { OperationLogger, createLoggerFromConfig, generateSessionId, getSessionId, setSessionId, } from './logging/index.js';
18
18
  export type { OperationLogEntry, SessionMetrics, AggregatedMetrics, LoggingConfig, ProductId, } from './logging/index.js';
19
- export { openStateDb, deleteStateDb, stateDbExists, searchEntities, searchEntitiesPrefix, getEntityByName, getEntitiesByAlias, getAllEntitiesFromDb, getEntityIndexFromDb, recordEntityMention, getEntityRecency, getAllRecency, setWriteState, getWriteState, deleteWriteState, setFlywheelConfig, getFlywheelConfig, getAllFlywheelConfig, saveFlywheelConfigToDb, loadFlywheelConfigFromDb, getStateDbMetadata, recordMergeDismissal, getDismissedMergePairs, saveVaultIndexCache, loadVaultIndexCache, getVaultIndexCacheInfo, rebuildEntitiesFts, loadContentHashes, saveContentHashBatch, renameContentHash, deleteStateDbFiles, backupStateDb, preserveCorruptedDb, BACKUP_ROTATION_COUNT, SALVAGE_TABLES, rotateBackupFiles, safeBackupAsync, checkDbIntegrity, salvageFeedbackTables, attemptSalvage, SCHEMA_VERSION, STATE_DB_FILENAME, FLYWHEEL_DIR, } from './sqlite.js';
19
+ export { openStateDb, deleteStateDb, stateDbExists, searchEntities, searchEntitiesPrefix, getEntityByName, getEntitiesByAlias, getAllEntitiesFromDb, getEntityIndexFromDb, recordEntityMention, getEntityRecency, getAllRecency, setWriteState, getWriteState, deleteWriteState, setFlywheelConfig, getFlywheelConfig, getAllFlywheelConfig, saveFlywheelConfigToDb, loadFlywheelConfigFromDb, getStateDbMetadata, recordMergeDismissal, getDismissedMergePairs, saveVaultIndexCache, loadVaultIndexCache, getVaultIndexCacheInfo, rebuildEntitiesFts, loadContentHashes, saveContentHashBatch, renameContentHash, deleteStateDbFiles, backupStateDb, preserveCorruptedDb, initSchema, migrateV40, BACKUP_ROTATION_COUNT, SALVAGE_TABLES, rotateBackupFiles, safeBackupAsync, checkDbIntegrity, salvageFeedbackTables, attemptSalvage, SCHEMA_VERSION, STATE_DB_FILENAME, FLYWHEEL_DIR, } from './sqlite.js';
20
20
  export type { StateDb, EntitySearchResult, RecencyRow, StateDbMetadata, VaultIndexCacheData, VaultIndexCacheInfo, FlywheelConfigRow, } from './sqlite.js';
21
21
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -35,6 +35,8 @@ saveVaultIndexCache, loadVaultIndexCache, getVaultIndexCacheInfo, rebuildEntitie
35
35
  loadContentHashes, saveContentHashBatch, renameContentHash,
36
36
  // Database file management
37
37
  deleteStateDbFiles, backupStateDb, preserveCorruptedDb,
38
+ // Migrations (exported for tests)
39
+ initSchema, migrateV40,
38
40
  // Backup & Recovery
39
41
  BACKUP_ROTATION_COUNT, SALVAGE_TABLES, rotateBackupFiles, safeBackupAsync, checkDbIntegrity, salvageFeedbackTables, attemptSalvage,
40
42
  // Constants
@@ -17,6 +17,40 @@ export declare function getStateDbPath(vaultPath: string): string;
17
17
  * Initialize schema and run migrations
18
18
  */
19
19
  export declare function initSchema(db: Database.Database): void;
20
+ /**
21
+ * Run the v40 migration: add COLLATE NOCASE to path columns across 14 tables,
22
+ * collapsing mixed-case duplicates with table-specific conflict resolution.
23
+ *
24
+ * Safety:
25
+ * - Wrapped in a single db.transaction(). better-sqlite3 supports transactional
26
+ * DDL (CREATE/DROP/ALTER RENAME participate in transactions), so any rebuild
27
+ * failure rolls back the whole batch. Partial-upgrade state is impossible.
28
+ * - Caller (openStateDb) runs a synchronous VACUUM INTO backup before calling
29
+ * initSchema when upgrading from < v40.
30
+ * - No VACUUM or PRAGMA statements inside the transaction (they auto-commit).
31
+ * - Foreign keys disabled for the duration to permit DROP TABLE on referenced
32
+ * tables. Re-enabled at the end.
33
+ *
34
+ * Conflict resolution per table (see p42 v40 plan S3 for full rationale):
35
+ *
36
+ * | Table | Rule |
37
+ * |------------------------|----------------------------------------------------|
38
+ * | entities | column alter (via rebuild) — no rows to merge |
39
+ * | note_embeddings | MAX(updated_at) |
40
+ * | content_hashes | MAX(updated_at) |
41
+ * | tasks | best-effort MAX(id); file scan reconciles |
42
+ * | note_links | MAX(weight_updated_at), keep matching weight |
43
+ * | note_tags | INSERT OR IGNORE — pure dedup, no values to merge |
44
+ * | note_link_history | MIN(first_seen_at), MAX(edits_survived/last_pos) |
45
+ * | note_moves | column alter — preserve all rows |
46
+ * | suggestion_events | MAX(total_score) per (timestamp, note, entity) |
47
+ * | corrections | column alter — preserve all rows |
48
+ * | prospect_ledger | MIN first_seen, MAX last_seen, SUM sightings |
49
+ * | proactive_queue | MAX(score), prefer 'pending' status |
50
+ * | retrieval_cooccurrence | SUM(weight), MIN(timestamp) |
51
+ * | wikilink_feedback | column alter — preserve all rows |
52
+ */
53
+ export declare function migrateV40(db: Database.Database): boolean;
20
54
  export declare function deleteStateDbFiles(dbPath: string): void;
21
55
  /** Back up state.db before opening (skip if missing or 0 bytes). */
22
56
  export declare function backupStateDb(dbPath: string): void;
@@ -336,8 +336,483 @@ export function initSchema(db) {
336
336
  }
337
337
  db.prepare('INSERT OR REPLACE INTO schema_version (version) VALUES (?)').run(38);
338
338
  }
339
- db.prepare('INSERT OR IGNORE INTO schema_version (version) VALUES (?)').run(SCHEMA_VERSION);
339
+ // v39: case-insensitive note_path on wikilink_applications unique index.
340
+ // On Windows NTFS / macOS APFS, `Flywheel.md` and `flywheel.md` are the
341
+ // same physical file. Without COLLATE NOCASE on note_path, mixed-case
342
+ // rows were legal and the same application could be recorded twice,
343
+ // doubling counts and breaking dedup in the doctor report (P42 issue 1).
344
+ if (currentVersion < 39) {
345
+ db.exec('DROP INDEX IF EXISTS idx_wl_apps_unique');
346
+ // Collapse any pre-existing duplicates before re-adding the unique index.
347
+ // Keep the lowest-id row per (entity NOCASE, note_path NOCASE) group.
348
+ db.exec(`
349
+ DELETE FROM wikilink_applications
350
+ WHERE id NOT IN (
351
+ SELECT MIN(id)
352
+ FROM wikilink_applications
353
+ GROUP BY LOWER(entity), LOWER(note_path)
354
+ )
355
+ `);
356
+ db.exec(`
357
+ CREATE UNIQUE INDEX idx_wl_apps_unique
358
+ ON wikilink_applications(entity COLLATE NOCASE, note_path COLLATE NOCASE)
359
+ `);
360
+ db.prepare('INSERT OR REPLACE INTO schema_version (version) VALUES (?)').run(39);
361
+ }
362
+ // v40: COLLATE NOCASE rollout across 14 more path columns.
363
+ // Case-insensitive filesystems (Windows NTFS, macOS APFS default) treat
364
+ // "Flywheel.md" and "flywheel.md" as the same file, but without collation
365
+ // both mixed-case variants could land in the state DB. v40 rebuilds the
366
+ // affected tables with COLLATE NOCASE on their path columns and collapses
367
+ // pre-existing dupes per table-specific rules (see migrateV40 below).
368
+ let v40Applied = true;
369
+ if (currentVersion < 40) {
370
+ v40Applied = migrateV40(db);
371
+ if (v40Applied) {
372
+ db.prepare('INSERT OR REPLACE INTO schema_version (version) VALUES (?)').run(40);
373
+ }
374
+ // Dry-run path: schema_version stays at 39. Server boots in degraded state.
375
+ }
376
+ // Only stamp SCHEMA_VERSION at the end if every migration ran. Dry-run
377
+ // skips v40 → leave schema_version at 39 so the next non-dry-run boot
378
+ // re-enters the v40 branch.
379
+ if (v40Applied) {
380
+ db.prepare('INSERT OR IGNORE INTO schema_version (version) VALUES (?)').run(SCHEMA_VERSION);
381
+ }
382
+ }
383
+ }
384
+ // =============================================================================
385
+ // v40 Migration: COLLATE NOCASE rollout
386
+ // =============================================================================
387
+ /**
388
+ * Run the v40 migration: add COLLATE NOCASE to path columns across 14 tables,
389
+ * collapsing mixed-case duplicates with table-specific conflict resolution.
390
+ *
391
+ * Safety:
392
+ * - Wrapped in a single db.transaction(). better-sqlite3 supports transactional
393
+ * DDL (CREATE/DROP/ALTER RENAME participate in transactions), so any rebuild
394
+ * failure rolls back the whole batch. Partial-upgrade state is impossible.
395
+ * - Caller (openStateDb) runs a synchronous VACUUM INTO backup before calling
396
+ * initSchema when upgrading from < v40.
397
+ * - No VACUUM or PRAGMA statements inside the transaction (they auto-commit).
398
+ * - Foreign keys disabled for the duration to permit DROP TABLE on referenced
399
+ * tables. Re-enabled at the end.
400
+ *
401
+ * Conflict resolution per table (see p42 v40 plan S3 for full rationale):
402
+ *
403
+ * | Table | Rule |
404
+ * |------------------------|----------------------------------------------------|
405
+ * | entities | column alter (via rebuild) — no rows to merge |
406
+ * | note_embeddings | MAX(updated_at) |
407
+ * | content_hashes | MAX(updated_at) |
408
+ * | tasks | best-effort MAX(id); file scan reconciles |
409
+ * | note_links | MAX(weight_updated_at), keep matching weight |
410
+ * | note_tags | INSERT OR IGNORE — pure dedup, no values to merge |
411
+ * | note_link_history | MIN(first_seen_at), MAX(edits_survived/last_pos) |
412
+ * | note_moves | column alter — preserve all rows |
413
+ * | suggestion_events | MAX(total_score) per (timestamp, note, entity) |
414
+ * | corrections | column alter — preserve all rows |
415
+ * | prospect_ledger | MIN first_seen, MAX last_seen, SUM sightings |
416
+ * | proactive_queue | MAX(score), prefer 'pending' status |
417
+ * | retrieval_cooccurrence | SUM(weight), MIN(timestamp) |
418
+ * | wikilink_feedback | column alter — preserve all rows |
419
+ */
420
+ export function migrateV40(db) {
421
+ // Log pre-migration collision counts so users see what's about to collapse.
422
+ // Counts are best-effort: tables that don't exist yet (fresh DB) are skipped.
423
+ const collisionProbes = [
424
+ { table: 'entities', pathCol: 'path' },
425
+ { table: 'note_embeddings', pathCol: 'path' },
426
+ { table: 'content_hashes', pathCol: 'path' },
427
+ { table: 'tasks', pathCol: 'path', extraCols: 'line' },
428
+ { table: 'note_links', pathCol: 'note_path', extraCols: 'target' },
429
+ { table: 'note_tags', pathCol: 'note_path', extraCols: 'tag' },
430
+ { table: 'note_link_history', pathCol: 'note_path', extraCols: 'target' },
431
+ { table: 'suggestion_events', pathCol: 'note_path', extraCols: 'timestamp, entity' },
432
+ { table: 'prospect_ledger', pathCol: 'note_path', extraCols: 'term, seen_day' },
433
+ { table: 'proactive_queue', pathCol: 'note_path', extraCols: 'entity' },
434
+ { table: 'retrieval_cooccurrence', pathCol: 'note_a', extraCols: 'note_b, session_id' },
435
+ ];
436
+ const collisions = [];
437
+ for (const probe of collisionProbes) {
438
+ try {
439
+ const groupCols = probe.extraCols
440
+ ? `LOWER(${probe.pathCol}), ${probe.extraCols}`
441
+ : `LOWER(${probe.pathCol})`;
442
+ const row = db.prepare(`SELECT COUNT(*) AS cnt FROM (
443
+ SELECT 1 FROM ${probe.table}
444
+ GROUP BY ${groupCols}
445
+ HAVING COUNT(*) > 1
446
+ )`).get();
447
+ if (row && row.cnt > 0) {
448
+ collisions.push({ table: probe.table, count: row.cnt });
449
+ }
450
+ }
451
+ catch {
452
+ // Table doesn't exist yet — skip silently.
453
+ }
454
+ }
455
+ if (collisions.length > 0) {
456
+ const summary = collisions.map(c => `${c.table}=${c.count}`).join(', ');
457
+ console.error(`[vault-core] v40 migration: collapsing mixed-case duplicates — ${summary}`);
458
+ }
459
+ else {
460
+ console.error('[vault-core] v40 migration: no mixed-case duplicates detected');
461
+ }
462
+ if (process.env.FLYWHEEL_MIGRATION_DRY_RUN === '1') {
463
+ console.error('[vault-core] FLYWHEEL_MIGRATION_DRY_RUN=1 — skipping v40 apply, DB stays at v39');
464
+ return false;
465
+ }
466
+ // Disable foreign keys for the rebuild. SQLite requires this off when
467
+ // renaming tables that may be referenced by others. Re-enabled after.
468
+ db.pragma('foreign_keys = OFF');
469
+ const runV40 = db.transaction(() => {
470
+ // --- entities: simple rebuild (all existing rows preserved) ---
471
+ db.exec(`
472
+ CREATE TABLE entities_v40_new (
473
+ id INTEGER PRIMARY KEY,
474
+ name TEXT NOT NULL,
475
+ name_lower TEXT NOT NULL,
476
+ path TEXT NOT NULL COLLATE NOCASE,
477
+ category TEXT NOT NULL,
478
+ aliases_json TEXT,
479
+ hub_score INTEGER DEFAULT 0,
480
+ description TEXT
481
+ );
482
+ INSERT INTO entities_v40_new SELECT id, name, name_lower, path, category, aliases_json, hub_score, description FROM entities;
483
+ DROP TABLE entities;
484
+ ALTER TABLE entities_v40_new RENAME TO entities;
485
+ `);
486
+ // --- note_embeddings: dedup by LOWER(path), keep MAX(updated_at).
487
+ // Window function ranks rows per case-folded path by updated_at desc,
488
+ // rowid asc as tie-break. Pick rank 1. ---
489
+ db.exec(`
490
+ CREATE TABLE note_embeddings_v40_new (
491
+ path TEXT PRIMARY KEY COLLATE NOCASE,
492
+ embedding BLOB NOT NULL,
493
+ content_hash TEXT NOT NULL,
494
+ model TEXT NOT NULL,
495
+ updated_at INTEGER NOT NULL
496
+ );
497
+ INSERT INTO note_embeddings_v40_new
498
+ SELECT path, embedding, content_hash, model, updated_at
499
+ FROM (
500
+ SELECT path, embedding, content_hash, model, updated_at,
501
+ ROW_NUMBER() OVER (PARTITION BY LOWER(path) ORDER BY updated_at DESC, rowid ASC) AS rn
502
+ FROM note_embeddings
503
+ ) WHERE rn = 1;
504
+ DROP TABLE note_embeddings;
505
+ ALTER TABLE note_embeddings_v40_new RENAME TO note_embeddings;
506
+ `);
507
+ // --- content_hashes: dedup by LOWER(path), keep MAX(updated_at).
508
+ // ROW_NUMBER() picks one row deterministically per case-folded path. ---
509
+ db.exec(`
510
+ CREATE TABLE content_hashes_v40_new (
511
+ path TEXT PRIMARY KEY COLLATE NOCASE,
512
+ hash TEXT NOT NULL,
513
+ updated_at INTEGER NOT NULL
514
+ );
515
+ INSERT INTO content_hashes_v40_new
516
+ SELECT path, hash, updated_at
517
+ FROM (
518
+ SELECT path, hash, updated_at,
519
+ ROW_NUMBER() OVER (PARTITION BY LOWER(path) ORDER BY updated_at DESC, rowid ASC) AS rn
520
+ FROM content_hashes
521
+ ) WHERE rn = 1;
522
+ DROP TABLE content_hashes;
523
+ ALTER TABLE content_hashes_v40_new RENAME TO content_hashes;
524
+ `);
525
+ // --- tasks: best-effort dedup by (LOWER(path), line), keep MAX(id).
526
+ // Post-boot file scan repopulates with the canonical filesystem case. ---
527
+ db.exec(`
528
+ CREATE TABLE tasks_v40_new (
529
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
530
+ path TEXT NOT NULL COLLATE NOCASE,
531
+ line INTEGER NOT NULL,
532
+ text TEXT NOT NULL,
533
+ status TEXT NOT NULL,
534
+ raw TEXT NOT NULL,
535
+ context TEXT,
536
+ tags_json TEXT,
537
+ due_date TEXT,
538
+ UNIQUE(path, line)
539
+ );
540
+ INSERT INTO tasks_v40_new
541
+ SELECT id, path, line, text, status, raw, context, tags_json, due_date
542
+ FROM (
543
+ SELECT id, path, line, text, status, raw, context, tags_json, due_date,
544
+ ROW_NUMBER() OVER (PARTITION BY LOWER(path), line ORDER BY id DESC) AS rn
545
+ FROM tasks
546
+ ) WHERE rn = 1;
547
+ DROP TABLE tasks;
548
+ ALTER TABLE tasks_v40_new RENAME TO tasks;
549
+ `);
550
+ // --- note_links: dedup by (LOWER(note_path), target), keep row with the
551
+ // latest weight_updated_at. Treat NULL as 0 for ordering so non-NULL
552
+ // wins. rowid tiebreak keeps the pick deterministic. ---
553
+ db.exec(`
554
+ CREATE TABLE note_links_v40_new (
555
+ note_path TEXT NOT NULL COLLATE NOCASE,
556
+ target TEXT NOT NULL,
557
+ weight REAL NOT NULL DEFAULT 1.0,
558
+ weight_updated_at INTEGER,
559
+ PRIMARY KEY (note_path, target)
560
+ );
561
+ INSERT INTO note_links_v40_new
562
+ SELECT note_path, target, weight, weight_updated_at
563
+ FROM (
564
+ SELECT note_path, target, weight, weight_updated_at,
565
+ ROW_NUMBER() OVER (
566
+ PARTITION BY LOWER(note_path), target
567
+ ORDER BY COALESCE(weight_updated_at, 0) DESC, rowid ASC
568
+ ) AS rn
569
+ FROM note_links
570
+ ) WHERE rn = 1;
571
+ DROP TABLE note_links;
572
+ ALTER TABLE note_links_v40_new RENAME TO note_links;
573
+ `);
574
+ // --- note_tags: pure dedup via INSERT OR IGNORE. No value columns to merge. ---
575
+ db.exec(`
576
+ CREATE TABLE note_tags_v40_new (
577
+ note_path TEXT NOT NULL COLLATE NOCASE,
578
+ tag TEXT NOT NULL,
579
+ PRIMARY KEY (note_path, tag)
580
+ );
581
+ INSERT OR IGNORE INTO note_tags_v40_new SELECT note_path, tag FROM note_tags;
582
+ DROP TABLE note_tags;
583
+ ALTER TABLE note_tags_v40_new RENAME TO note_tags;
584
+ `);
585
+ // --- note_link_history: MIN(first_seen_at), MAX(edits_survived, last_positive_at) ---
586
+ db.exec(`
587
+ CREATE TABLE note_link_history_v40_new (
588
+ note_path TEXT NOT NULL COLLATE NOCASE,
589
+ target TEXT NOT NULL,
590
+ first_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
591
+ edits_survived INTEGER NOT NULL DEFAULT 0,
592
+ last_positive_at TEXT,
593
+ PRIMARY KEY (note_path, target)
594
+ );
595
+ INSERT INTO note_link_history_v40_new
596
+ SELECT MIN(note_path), target, MIN(first_seen_at), MAX(edits_survived), MAX(last_positive_at)
597
+ FROM note_link_history
598
+ GROUP BY LOWER(note_path), target;
599
+ DROP TABLE note_link_history;
600
+ ALTER TABLE note_link_history_v40_new RENAME TO note_link_history;
601
+ `);
602
+ // --- note_moves: column alter via rebuild. All rows preserved (no dedup;
603
+ // history is append-only, old/new paths are legitimately case-variant). ---
604
+ db.exec(`
605
+ CREATE TABLE note_moves_v40_new (
606
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
607
+ old_path TEXT NOT NULL COLLATE NOCASE,
608
+ new_path TEXT NOT NULL COLLATE NOCASE,
609
+ moved_at TEXT NOT NULL DEFAULT (datetime('now')),
610
+ old_folder TEXT,
611
+ new_folder TEXT
612
+ );
613
+ INSERT INTO note_moves_v40_new SELECT id, old_path, new_path, moved_at, old_folder, new_folder FROM note_moves;
614
+ DROP TABLE note_moves;
615
+ ALTER TABLE note_moves_v40_new RENAME TO note_moves;
616
+ `);
617
+ // --- suggestion_events: dedup by (timestamp, LOWER(note_path), entity),
618
+ // keep row with MAX(total_score), id DESC tiebreak. ---
619
+ db.exec(`
620
+ CREATE TABLE suggestion_events_v40_new (
621
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
622
+ timestamp INTEGER NOT NULL,
623
+ note_path TEXT NOT NULL COLLATE NOCASE,
624
+ entity TEXT NOT NULL,
625
+ total_score REAL NOT NULL,
626
+ breakdown_json TEXT NOT NULL,
627
+ threshold REAL NOT NULL,
628
+ passed INTEGER NOT NULL,
629
+ strictness TEXT NOT NULL,
630
+ applied INTEGER DEFAULT 0,
631
+ pipeline_event_id INTEGER,
632
+ UNIQUE(timestamp, note_path, entity)
633
+ );
634
+ INSERT INTO suggestion_events_v40_new
635
+ SELECT id, timestamp, note_path, entity, total_score, breakdown_json,
636
+ threshold, passed, strictness, applied, pipeline_event_id
637
+ FROM (
638
+ SELECT id, timestamp, note_path, entity, total_score, breakdown_json,
639
+ threshold, passed, strictness, applied, pipeline_event_id,
640
+ ROW_NUMBER() OVER (
641
+ PARTITION BY timestamp, LOWER(note_path), entity
642
+ ORDER BY total_score DESC, id DESC
643
+ ) AS rn
644
+ FROM suggestion_events
645
+ ) WHERE rn = 1;
646
+ DROP TABLE suggestion_events;
647
+ ALTER TABLE suggestion_events_v40_new RENAME TO suggestion_events;
648
+ `);
649
+ // --- corrections: column alter via rebuild. All rows preserved. ---
650
+ db.exec(`
651
+ CREATE TABLE corrections_v40_new (
652
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
653
+ entity TEXT,
654
+ note_path TEXT COLLATE NOCASE,
655
+ correction_type TEXT NOT NULL,
656
+ description TEXT NOT NULL,
657
+ source TEXT NOT NULL DEFAULT 'user',
658
+ status TEXT DEFAULT 'pending',
659
+ created_at TEXT DEFAULT (datetime('now')),
660
+ resolved_at TEXT
661
+ );
662
+ INSERT INTO corrections_v40_new
663
+ SELECT id, entity, note_path, correction_type, description, source, status, created_at, resolved_at
664
+ FROM corrections;
665
+ DROP TABLE corrections;
666
+ ALTER TABLE corrections_v40_new RENAME TO corrections;
667
+ `);
668
+ // --- prospect_ledger: aggregate sums (sighting_count, score, backlink_count,
669
+ // first/last_seen_at) plus non-aggregate cols (display_name, source,
670
+ // pattern, confidence) taken from the row with the latest last_seen_at.
671
+ // CTE: agg computes the sums; ranked picks the winning row per group;
672
+ // INSERT joins the two. ---
673
+ db.exec(`
674
+ CREATE TABLE prospect_ledger_v40_new (
675
+ term TEXT NOT NULL,
676
+ display_name TEXT NOT NULL,
677
+ note_path TEXT NOT NULL COLLATE NOCASE,
678
+ seen_day TEXT NOT NULL,
679
+ source TEXT NOT NULL,
680
+ pattern TEXT,
681
+ confidence TEXT NOT NULL DEFAULT 'low',
682
+ backlink_count INTEGER DEFAULT 0,
683
+ score REAL DEFAULT 0,
684
+ first_seen_at INTEGER NOT NULL,
685
+ last_seen_at INTEGER NOT NULL,
686
+ sighting_count INTEGER NOT NULL DEFAULT 1,
687
+ PRIMARY KEY (term, note_path, seen_day)
688
+ );
689
+ INSERT INTO prospect_ledger_v40_new
690
+ WITH agg AS (
691
+ SELECT
692
+ term AS tm,
693
+ LOWER(note_path) AS lnp,
694
+ seen_day AS sd,
695
+ MIN(first_seen_at) AS first_seen,
696
+ MAX(last_seen_at) AS last_seen,
697
+ SUM(sighting_count) AS total_sightings,
698
+ MAX(score) AS best_score,
699
+ MAX(backlink_count) AS total_backlinks
700
+ FROM prospect_ledger
701
+ GROUP BY term, LOWER(note_path), seen_day
702
+ ),
703
+ ranked AS (
704
+ SELECT term, display_name, note_path, seen_day, source, pattern, confidence,
705
+ ROW_NUMBER() OVER (
706
+ PARTITION BY term, LOWER(note_path), seen_day
707
+ ORDER BY last_seen_at DESC, rowid ASC
708
+ ) AS rn
709
+ FROM prospect_ledger
710
+ )
711
+ SELECT
712
+ r.term,
713
+ r.display_name,
714
+ r.note_path,
715
+ r.seen_day,
716
+ r.source,
717
+ r.pattern,
718
+ r.confidence,
719
+ COALESCE(a.total_backlinks, 0) AS backlink_count,
720
+ COALESCE(a.best_score, 0) AS score,
721
+ a.first_seen AS first_seen_at,
722
+ a.last_seen AS last_seen_at,
723
+ a.total_sightings AS sighting_count
724
+ FROM ranked r
725
+ INNER JOIN agg a
726
+ ON r.term = a.tm AND LOWER(r.note_path) = a.lnp AND r.seen_day = a.sd
727
+ WHERE r.rn = 1;
728
+ DROP TABLE prospect_ledger;
729
+ ALTER TABLE prospect_ledger_v40_new RENAME TO prospect_ledger;
730
+ `);
731
+ // --- proactive_queue: dedup by (LOWER(note_path), entity). Keep row with
732
+ // MAX(score); on score tie, prefer status='pending' over 'applied' (so
733
+ // unfinished work survives); then latest queued_at; then highest id. ---
734
+ db.exec(`
735
+ CREATE TABLE proactive_queue_v40_new (
736
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
737
+ note_path TEXT NOT NULL COLLATE NOCASE,
738
+ entity TEXT NOT NULL,
739
+ score REAL NOT NULL,
740
+ confidence TEXT NOT NULL,
741
+ queued_at INTEGER NOT NULL,
742
+ expires_at INTEGER NOT NULL,
743
+ status TEXT NOT NULL DEFAULT 'pending',
744
+ applied_at INTEGER,
745
+ UNIQUE(note_path, entity)
746
+ );
747
+ INSERT INTO proactive_queue_v40_new
748
+ SELECT id, note_path, entity, score, confidence,
749
+ queued_at, expires_at, status, applied_at
750
+ FROM (
751
+ SELECT id, note_path, entity, score, confidence,
752
+ queued_at, expires_at, status, applied_at,
753
+ ROW_NUMBER() OVER (
754
+ PARTITION BY LOWER(note_path), entity
755
+ ORDER BY score DESC,
756
+ CASE WHEN status = 'pending' THEN 0 ELSE 1 END ASC,
757
+ queued_at DESC,
758
+ id DESC
759
+ ) AS rn
760
+ FROM proactive_queue
761
+ ) WHERE rn = 1;
762
+ DROP TABLE proactive_queue;
763
+ ALTER TABLE proactive_queue_v40_new RENAME TO proactive_queue;
764
+ `);
765
+ // --- retrieval_cooccurrence: SUM(weight), MIN(timestamp) per
766
+ // (LOWER(note_a), LOWER(note_b), session_id) ---
767
+ db.exec(`
768
+ CREATE TABLE retrieval_cooccurrence_v40_new (
769
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
770
+ note_a TEXT NOT NULL COLLATE NOCASE,
771
+ note_b TEXT NOT NULL COLLATE NOCASE,
772
+ session_id TEXT NOT NULL,
773
+ timestamp INTEGER NOT NULL,
774
+ weight REAL NOT NULL DEFAULT 1.0,
775
+ UNIQUE(note_a, note_b, session_id)
776
+ );
777
+ INSERT INTO retrieval_cooccurrence_v40_new (note_a, note_b, session_id, timestamp, weight)
778
+ SELECT MIN(note_a), MIN(note_b), session_id, MIN(timestamp), SUM(weight)
779
+ FROM retrieval_cooccurrence
780
+ GROUP BY LOWER(note_a), LOWER(note_b), session_id;
781
+ DROP TABLE retrieval_cooccurrence;
782
+ ALTER TABLE retrieval_cooccurrence_v40_new RENAME TO retrieval_cooccurrence;
783
+ `);
784
+ // --- wikilink_feedback: column alter via rebuild. All rows preserved. ---
785
+ db.exec(`
786
+ CREATE TABLE wikilink_feedback_v40_new (
787
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
788
+ entity TEXT NOT NULL,
789
+ context TEXT NOT NULL,
790
+ note_path TEXT NOT NULL COLLATE NOCASE,
791
+ correct INTEGER NOT NULL,
792
+ confidence REAL NOT NULL DEFAULT 1.0,
793
+ matched_term TEXT,
794
+ created_at TEXT DEFAULT (datetime('now'))
795
+ );
796
+ INSERT INTO wikilink_feedback_v40_new
797
+ SELECT id, entity, context, note_path, correct, confidence, matched_term, created_at
798
+ FROM wikilink_feedback;
799
+ DROP TABLE wikilink_feedback;
800
+ ALTER TABLE wikilink_feedback_v40_new RENAME TO wikilink_feedback;
801
+ `);
802
+ // Recreate all indexes stripped by DROP TABLE. Re-executing SCHEMA_SQL
803
+ // is safe inside the transaction: CREATE TABLE IF NOT EXISTS is a no-op
804
+ // for the renamed tables, and CREATE INDEX IF NOT EXISTS repopulates
805
+ // the missing indexes. Triggers on entities_fts also get re-created.
806
+ db.exec(SCHEMA_SQL);
807
+ });
808
+ try {
809
+ runV40();
810
+ }
811
+ finally {
812
+ // Always re-enable foreign keys, even if the transaction threw and rolled back.
813
+ db.pragma('foreign_keys = ON');
340
814
  }
815
+ return true;
341
816
  }
342
817
  // =============================================================================
343
818
  // Database File Management
package/dist/schema.d.ts CHANGED
@@ -5,10 +5,10 @@
5
5
  * for the flywheel state database.
6
6
  */
7
7
  /** Current schema version - bump when schema changes */
8
- export declare const SCHEMA_VERSION = 38;
8
+ export declare const SCHEMA_VERSION = 40;
9
9
  /** State database filename */
10
10
  export declare const STATE_DB_FILENAME = "state.db";
11
11
  /** Directory for flywheel state */
12
12
  export declare const FLYWHEEL_DIR = ".flywheel";
13
- export declare const SCHEMA_SQL = "\n-- Schema version tracking\nCREATE TABLE IF NOT EXISTS schema_version (\n version INTEGER PRIMARY KEY,\n applied_at TEXT DEFAULT (datetime('now'))\n);\n\n-- Metadata key-value store\nCREATE TABLE IF NOT EXISTS metadata (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL,\n updated_at TEXT DEFAULT (datetime('now'))\n);\n\n-- Entity index (replaces wikilink-entities.json)\nCREATE TABLE IF NOT EXISTS entities (\n id INTEGER PRIMARY KEY,\n name TEXT NOT NULL,\n name_lower TEXT NOT NULL,\n path TEXT NOT NULL,\n category TEXT NOT NULL,\n aliases_json TEXT,\n hub_score INTEGER DEFAULT 0,\n description TEXT\n);\nCREATE INDEX IF NOT EXISTS idx_entities_name_lower ON entities(name_lower);\nCREATE INDEX IF NOT EXISTS idx_entities_category ON entities(category);\n\n-- FTS5 for entity search with porter stemmer (contentless \u2014 triggers handle sync)\nCREATE VIRTUAL TABLE IF NOT EXISTS entities_fts USING fts5(\n name, aliases, category,\n content='',\n tokenize='porter unicode61'\n);\n\n-- Auto-sync triggers for entities_fts\nCREATE TRIGGER IF NOT EXISTS entities_ai AFTER INSERT ON entities BEGIN\n INSERT INTO entities_fts(rowid, name, aliases, category)\n VALUES (\n new.id,\n new.name,\n COALESCE((SELECT group_concat(value, ' ') FROM json_each(new.aliases_json)), ''),\n new.category\n );\nEND;\n\nCREATE TRIGGER IF NOT EXISTS entities_ad AFTER DELETE ON entities BEGIN\n INSERT INTO entities_fts(entities_fts, rowid, name, aliases, category)\n VALUES (\n 'delete',\n old.id,\n old.name,\n COALESCE((SELECT group_concat(value, ' ') FROM json_each(old.aliases_json)), ''),\n old.category\n );\nEND;\n\nCREATE TRIGGER IF NOT EXISTS entities_au AFTER UPDATE ON entities BEGIN\n INSERT INTO entities_fts(entities_fts, rowid, name, aliases, category)\n VALUES (\n 'delete',\n old.id,\n old.name,\n COALESCE((SELECT group_concat(value, ' ') FROM json_each(old.aliases_json)), ''),\n old.category\n );\n INSERT INTO entities_fts(rowid, name, aliases, category)\n VALUES (\n new.id,\n new.name,\n COALESCE((SELECT group_concat(value, ' ') FROM json_each(new.aliases_json)), ''),\n new.category\n );\nEND;\n\n-- Recency tracking (replaces entity-recency.json)\nCREATE TABLE IF NOT EXISTS recency (\n entity_name_lower TEXT PRIMARY KEY,\n last_mentioned_at INTEGER NOT NULL,\n mention_count INTEGER DEFAULT 1\n);\n\n-- Write state (replaces last-commit.json and other write state)\nCREATE TABLE IF NOT EXISTS write_state (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL,\n updated_at TEXT DEFAULT (datetime('now'))\n);\n\n-- Content search FTS5 (migrated from vault-search.db)\n-- v11: Added frontmatter column for weighted search (path, title, frontmatter, content)\nCREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(\n path, title, frontmatter, content,\n tokenize='porter'\n);\n\n-- FTS5 build metadata (consolidated from vault-search.db)\nCREATE TABLE IF NOT EXISTS fts_metadata (\n key TEXT PRIMARY KEY,\n value TEXT\n);\n\n-- Vault index cache (for fast startup)\n-- Stores serialized VaultIndex to avoid full rebuild on startup\nCREATE TABLE IF NOT EXISTS vault_index_cache (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n data BLOB NOT NULL,\n built_at INTEGER NOT NULL,\n note_count INTEGER NOT NULL,\n version INTEGER DEFAULT 1\n);\n\n-- Flywheel configuration (replaces .flywheel.json)\nCREATE TABLE IF NOT EXISTS flywheel_config (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL,\n updated_at TEXT DEFAULT (datetime('now'))\n);\n\n-- Vault metrics (v4: growth tracking)\nCREATE TABLE IF NOT EXISTS vault_metrics (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n timestamp INTEGER NOT NULL,\n metric TEXT NOT NULL,\n value REAL NOT NULL\n);\nCREATE INDEX IF NOT EXISTS idx_vault_metrics_ts ON vault_metrics(timestamp);\nCREATE INDEX IF NOT EXISTS idx_vault_metrics_m ON vault_metrics(metric, timestamp);\n\n-- Wikilink feedback (v4: quality tracking)\nCREATE TABLE IF NOT EXISTS wikilink_feedback (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n entity TEXT NOT NULL,\n context TEXT NOT NULL,\n note_path TEXT NOT NULL,\n correct INTEGER NOT NULL,\n confidence REAL NOT NULL DEFAULT 1.0,\n matched_term TEXT,\n created_at TEXT DEFAULT (datetime('now'))\n);\nCREATE INDEX IF NOT EXISTS idx_wl_feedback_entity ON wikilink_feedback(entity);\nCREATE INDEX IF NOT EXISTS idx_wl_feedback_note_path ON wikilink_feedback(note_path);\n\n-- Wikilink suppressions (v4: auto-suppress false positives)\nCREATE TABLE IF NOT EXISTS wikilink_suppressions (\n entity TEXT PRIMARY KEY,\n false_positive_rate REAL NOT NULL,\n updated_at TEXT DEFAULT (datetime('now'))\n);\n\n-- Wikilink applications tracking (v5: implicit feedback, v38: source provenance)\nCREATE TABLE IF NOT EXISTS wikilink_applications (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n entity TEXT NOT NULL,\n note_path TEXT NOT NULL,\n matched_term TEXT,\n applied_at TEXT DEFAULT (datetime('now')),\n status TEXT DEFAULT 'applied',\n source TEXT NOT NULL DEFAULT 'tool'\n);\nCREATE UNIQUE INDEX IF NOT EXISTS idx_wl_apps_unique ON wikilink_applications(entity COLLATE NOCASE, note_path);\n\n-- Index events tracking (v6: index activity history)\nCREATE TABLE IF NOT EXISTS index_events (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n timestamp INTEGER NOT NULL,\n trigger TEXT NOT NULL,\n duration_ms INTEGER NOT NULL,\n success INTEGER NOT NULL DEFAULT 1,\n note_count INTEGER,\n files_changed INTEGER,\n changed_paths TEXT,\n error TEXT,\n steps TEXT\n);\nCREATE INDEX IF NOT EXISTS idx_index_events_ts ON index_events(timestamp);\n\n-- Tool invocation tracking (v7: usage analytics)\nCREATE TABLE IF NOT EXISTS tool_invocations (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n timestamp INTEGER NOT NULL,\n tool_name TEXT NOT NULL,\n session_id TEXT,\n note_paths TEXT,\n duration_ms INTEGER,\n success INTEGER NOT NULL DEFAULT 1,\n response_tokens INTEGER,\n baseline_tokens INTEGER,\n query_context TEXT\n);\nCREATE INDEX IF NOT EXISTS idx_tool_inv_ts ON tool_invocations(timestamp);\nCREATE INDEX IF NOT EXISTS idx_tool_inv_tool ON tool_invocations(tool_name, timestamp);\nCREATE INDEX IF NOT EXISTS idx_tool_inv_session ON tool_invocations(session_id, timestamp);\n\n-- Graph topology snapshots (v8: structural evolution)\nCREATE TABLE IF NOT EXISTS graph_snapshots (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n timestamp INTEGER NOT NULL,\n metric TEXT NOT NULL,\n value REAL NOT NULL,\n details TEXT\n);\nCREATE INDEX IF NOT EXISTS idx_graph_snap_ts ON graph_snapshots(timestamp);\nCREATE INDEX IF NOT EXISTS idx_graph_snap_m ON graph_snapshots(metric, timestamp);\n\n-- Note embeddings for semantic search (v9)\nCREATE TABLE IF NOT EXISTS note_embeddings (\n path TEXT PRIMARY KEY,\n embedding BLOB NOT NULL,\n content_hash TEXT NOT NULL,\n model TEXT NOT NULL,\n updated_at INTEGER NOT NULL\n);\n\n-- Entity embeddings for semantic entity search (v10)\nCREATE TABLE IF NOT EXISTS entity_embeddings (\n entity_name TEXT PRIMARY KEY,\n embedding BLOB NOT NULL,\n source_hash TEXT NOT NULL,\n model TEXT NOT NULL,\n updated_at INTEGER NOT NULL\n);\n\n-- Task cache for fast task queries (v12)\nCREATE TABLE IF NOT EXISTS tasks (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n path TEXT NOT NULL,\n line INTEGER NOT NULL,\n text TEXT NOT NULL,\n status TEXT NOT NULL,\n raw TEXT NOT NULL,\n context TEXT,\n tags_json TEXT,\n due_date TEXT,\n UNIQUE(path, line)\n);\nCREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);\nCREATE INDEX IF NOT EXISTS idx_tasks_path ON tasks(path);\nCREATE INDEX IF NOT EXISTS idx_tasks_due ON tasks(due_date);\n\n-- Merge dismissals (v13: persistent merge pair suppression)\nCREATE TABLE IF NOT EXISTS merge_dismissals (\n pair_key TEXT PRIMARY KEY,\n source_path TEXT NOT NULL,\n target_path TEXT NOT NULL,\n source_name TEXT NOT NULL,\n target_name TEXT NOT NULL,\n reason TEXT NOT NULL,\n dismissed_at TEXT DEFAULT (datetime('now'))\n);\n\n-- Suggestion events audit log (v15: pipeline observability)\nCREATE TABLE IF NOT EXISTS suggestion_events (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n timestamp INTEGER NOT NULL,\n note_path TEXT NOT NULL,\n entity TEXT NOT NULL,\n total_score REAL NOT NULL,\n breakdown_json TEXT NOT NULL,\n threshold REAL NOT NULL,\n passed INTEGER NOT NULL,\n strictness TEXT NOT NULL,\n applied INTEGER DEFAULT 0,\n pipeline_event_id INTEGER,\n UNIQUE(timestamp, note_path, entity)\n);\nCREATE INDEX IF NOT EXISTS idx_suggestion_entity ON suggestion_events(entity);\nCREATE INDEX IF NOT EXISTS idx_suggestion_note ON suggestion_events(note_path);\n\n-- Forward-link persistence for diff-based feedback (v16), edge weights (v22)\nCREATE TABLE IF NOT EXISTS note_links (\n note_path TEXT NOT NULL,\n target TEXT NOT NULL,\n weight REAL NOT NULL DEFAULT 1.0,\n weight_updated_at INTEGER,\n PRIMARY KEY (note_path, target)\n);\n\n-- Entity field change audit log (v17, rowid PK since v32)\nCREATE TABLE IF NOT EXISTS entity_changes (\n entity TEXT NOT NULL,\n field TEXT NOT NULL,\n old_value TEXT,\n new_value TEXT,\n changed_at TEXT NOT NULL DEFAULT (datetime('now'))\n);\n\n-- Note tag persistence for diff-based feedback (v18)\nCREATE TABLE IF NOT EXISTS note_tags (\n note_path TEXT NOT NULL,\n tag TEXT NOT NULL,\n PRIMARY KEY (note_path, tag)\n);\n\n-- Wikilink survival tracking for positive feedback signals (v19)\nCREATE TABLE IF NOT EXISTS note_link_history (\n note_path TEXT NOT NULL,\n target TEXT NOT NULL,\n first_seen_at TEXT NOT NULL DEFAULT (datetime('now')),\n edits_survived INTEGER NOT NULL DEFAULT 0,\n last_positive_at TEXT,\n PRIMARY KEY (note_path, target)\n);\n\n-- Note move history (v20): records when files are moved/renamed to a different folder\nCREATE TABLE IF NOT EXISTS note_moves (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n old_path TEXT NOT NULL,\n new_path TEXT NOT NULL,\n moved_at TEXT NOT NULL DEFAULT (datetime('now')),\n old_folder TEXT,\n new_folder TEXT\n);\nCREATE INDEX IF NOT EXISTS idx_note_moves_old_path ON note_moves(old_path);\nCREATE INDEX IF NOT EXISTS idx_note_moves_new_path ON note_moves(new_path);\nCREATE INDEX IF NOT EXISTS idx_note_moves_moved_at ON note_moves(moved_at);\n\n-- Corrections (v24): persistent correction records from user/engine feedback\nCREATE TABLE IF NOT EXISTS corrections (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n entity TEXT,\n note_path TEXT,\n correction_type TEXT NOT NULL,\n description TEXT NOT NULL,\n source TEXT NOT NULL DEFAULT 'user',\n status TEXT DEFAULT 'pending',\n created_at TEXT DEFAULT (datetime('now')),\n resolved_at TEXT\n);\nCREATE INDEX IF NOT EXISTS idx_corrections_status ON corrections(status);\nCREATE INDEX IF NOT EXISTS idx_corrections_entity ON corrections(entity);\n\n-- Memories (v26): lightweight key-value working memory for agents\nCREATE TABLE IF NOT EXISTS memories (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n key TEXT NOT NULL,\n value TEXT NOT NULL,\n memory_type TEXT NOT NULL,\n entity TEXT,\n entities_json TEXT,\n source_agent_id TEXT,\n source_session_id TEXT,\n confidence REAL NOT NULL DEFAULT 1.0,\n created_at INTEGER NOT NULL,\n updated_at INTEGER NOT NULL,\n accessed_at INTEGER NOT NULL,\n ttl_days INTEGER,\n superseded_by INTEGER REFERENCES memories(id),\n visibility TEXT NOT NULL DEFAULT 'shared'\n);\nCREATE INDEX IF NOT EXISTS idx_memories_key ON memories(key);\nCREATE INDEX IF NOT EXISTS idx_memories_entity ON memories(entity);\nCREATE INDEX IF NOT EXISTS idx_memories_type ON memories(memory_type);\n\nCREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(\n key, value,\n content=memories, content_rowid=id,\n tokenize='porter unicode61'\n);\n\n-- Auto-sync triggers for memories_fts\nCREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN\n INSERT INTO memories_fts(rowid, key, value)\n VALUES (new.id, new.key, new.value);\nEND;\n\nCREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN\n INSERT INTO memories_fts(memories_fts, rowid, key, value)\n VALUES ('delete', old.id, old.key, old.value);\nEND;\n\nCREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN\n INSERT INTO memories_fts(memories_fts, rowid, key, value)\n VALUES ('delete', old.id, old.key, old.value);\n INSERT INTO memories_fts(rowid, key, value)\n VALUES (new.id, new.key, new.value);\nEND;\n\n-- Co-occurrence cache (v27): persist co-occurrence index to avoid full vault scan on restart\nCREATE TABLE IF NOT EXISTS cooccurrence_cache (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n data TEXT NOT NULL,\n built_at INTEGER NOT NULL,\n entity_count INTEGER NOT NULL,\n association_count INTEGER NOT NULL\n);\n\n-- Content hashes (v28): persist watcher content hashes across restarts\nCREATE TABLE IF NOT EXISTS content_hashes (\n path TEXT PRIMARY KEY,\n hash TEXT NOT NULL,\n updated_at INTEGER NOT NULL\n);\n\n-- Session summaries (v26): agent session tracking\nCREATE TABLE IF NOT EXISTS session_summaries (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n session_id TEXT NOT NULL UNIQUE,\n summary TEXT NOT NULL,\n topics_json TEXT,\n notes_modified_json TEXT,\n agent_id TEXT,\n started_at INTEGER,\n ended_at INTEGER NOT NULL,\n tool_count INTEGER\n);\n\n-- Retrieval co-occurrence (v30): notes retrieved together build implicit edges\nCREATE TABLE IF NOT EXISTS retrieval_cooccurrence (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n note_a TEXT NOT NULL,\n note_b TEXT NOT NULL,\n session_id TEXT NOT NULL,\n timestamp INTEGER NOT NULL,\n weight REAL NOT NULL DEFAULT 1.0,\n UNIQUE(note_a, note_b, session_id)\n);\nCREATE INDEX IF NOT EXISTS idx_retcooc_notes ON retrieval_cooccurrence(note_a, note_b);\nCREATE INDEX IF NOT EXISTS idx_retcooc_ts ON retrieval_cooccurrence(timestamp);\n\n-- Deferred proactive linking queue (v31)\nCREATE TABLE IF NOT EXISTS proactive_queue (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n note_path TEXT NOT NULL,\n entity TEXT NOT NULL,\n score REAL NOT NULL,\n confidence TEXT NOT NULL,\n queued_at INTEGER NOT NULL,\n expires_at INTEGER NOT NULL,\n status TEXT NOT NULL DEFAULT 'pending',\n applied_at INTEGER,\n UNIQUE(note_path, entity)\n);\nCREATE INDEX IF NOT EXISTS idx_pq_status ON proactive_queue(status, expires_at);\n\n-- Performance benchmarks (v33: longitudinal tracking)\nCREATE TABLE IF NOT EXISTS performance_benchmarks (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n timestamp INTEGER NOT NULL,\n version TEXT NOT NULL,\n benchmark TEXT NOT NULL,\n mean_ms REAL NOT NULL,\n p50_ms REAL,\n p95_ms REAL,\n iterations INTEGER NOT NULL DEFAULT 1\n);\nCREATE INDEX IF NOT EXISTS idx_perf_bench_ts ON performance_benchmarks(timestamp);\nCREATE INDEX IF NOT EXISTS idx_perf_bench_name ON performance_benchmarks(benchmark, timestamp);\n\n-- Tool selection feedback (v36: tool selection quality tracking)\nCREATE TABLE IF NOT EXISTS tool_selection_feedback (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n timestamp INTEGER NOT NULL,\n tool_invocation_id INTEGER,\n tool_name TEXT NOT NULL,\n query_context TEXT,\n expected_tool TEXT,\n expected_category TEXT,\n correct INTEGER,\n source TEXT NOT NULL DEFAULT 'explicit',\n rule_id TEXT,\n rule_version INTEGER,\n session_id TEXT,\n created_at TEXT DEFAULT (datetime('now'))\n);\nCREATE INDEX IF NOT EXISTS idx_tsf_tool ON tool_selection_feedback(tool_name);\nCREATE INDEX IF NOT EXISTS idx_tsf_ts ON tool_selection_feedback(timestamp);\n\n-- Prospect ledger (v37): day-grain sightings for pre-entity pattern accumulation\nCREATE TABLE IF NOT EXISTS prospect_ledger (\n term TEXT NOT NULL,\n display_name TEXT NOT NULL,\n note_path TEXT NOT NULL,\n seen_day TEXT NOT NULL,\n source TEXT NOT NULL,\n pattern TEXT,\n confidence TEXT NOT NULL DEFAULT 'low',\n backlink_count INTEGER DEFAULT 0,\n score REAL DEFAULT 0,\n first_seen_at INTEGER NOT NULL,\n last_seen_at INTEGER NOT NULL,\n sighting_count INTEGER NOT NULL DEFAULT 1,\n PRIMARY KEY (term, note_path, seen_day)\n);\nCREATE INDEX IF NOT EXISTS idx_prospect_term ON prospect_ledger(term);\nCREATE INDEX IF NOT EXISTS idx_prospect_last_seen ON prospect_ledger(last_seen_at);\n\n-- Prospect summary (v37): materialized aggregate for fast scoring\nCREATE TABLE IF NOT EXISTS prospect_summary (\n term TEXT PRIMARY KEY,\n display_name TEXT NOT NULL,\n note_count INTEGER NOT NULL DEFAULT 0,\n day_count INTEGER NOT NULL DEFAULT 0,\n total_sightings INTEGER NOT NULL DEFAULT 0,\n backlink_max INTEGER NOT NULL DEFAULT 0,\n cooccurring_entities TEXT,\n best_source TEXT NOT NULL DEFAULT 'implicit',\n best_confidence TEXT NOT NULL DEFAULT 'low',\n best_score REAL NOT NULL DEFAULT 0,\n first_seen_at INTEGER NOT NULL,\n last_seen_at INTEGER NOT NULL,\n promotion_score REAL NOT NULL DEFAULT 0,\n promoted_at INTEGER,\n updated_at INTEGER NOT NULL\n);\nCREATE INDEX IF NOT EXISTS idx_prospect_summary_score ON prospect_summary(promotion_score DESC);\n\n";
13
+ export declare const SCHEMA_SQL = "\n-- Schema version tracking\nCREATE TABLE IF NOT EXISTS schema_version (\n version INTEGER PRIMARY KEY,\n applied_at TEXT DEFAULT (datetime('now'))\n);\n\n-- Metadata key-value store\nCREATE TABLE IF NOT EXISTS metadata (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL,\n updated_at TEXT DEFAULT (datetime('now'))\n);\n\n-- Entity index (replaces wikilink-entities.json)\nCREATE TABLE IF NOT EXISTS entities (\n id INTEGER PRIMARY KEY,\n name TEXT NOT NULL,\n name_lower TEXT NOT NULL,\n path TEXT NOT NULL COLLATE NOCASE,\n category TEXT NOT NULL,\n aliases_json TEXT,\n hub_score INTEGER DEFAULT 0,\n description TEXT\n);\nCREATE INDEX IF NOT EXISTS idx_entities_name_lower ON entities(name_lower);\nCREATE INDEX IF NOT EXISTS idx_entities_category ON entities(category);\n\n-- FTS5 for entity search with porter stemmer (contentless \u2014 triggers handle sync)\nCREATE VIRTUAL TABLE IF NOT EXISTS entities_fts USING fts5(\n name, aliases, category,\n content='',\n tokenize='porter unicode61'\n);\n\n-- Auto-sync triggers for entities_fts\nCREATE TRIGGER IF NOT EXISTS entities_ai AFTER INSERT ON entities BEGIN\n INSERT INTO entities_fts(rowid, name, aliases, category)\n VALUES (\n new.id,\n new.name,\n COALESCE((SELECT group_concat(value, ' ') FROM json_each(new.aliases_json)), ''),\n new.category\n );\nEND;\n\nCREATE TRIGGER IF NOT EXISTS entities_ad AFTER DELETE ON entities BEGIN\n INSERT INTO entities_fts(entities_fts, rowid, name, aliases, category)\n VALUES (\n 'delete',\n old.id,\n old.name,\n COALESCE((SELECT group_concat(value, ' ') FROM json_each(old.aliases_json)), ''),\n old.category\n );\nEND;\n\nCREATE TRIGGER IF NOT EXISTS entities_au AFTER UPDATE ON entities BEGIN\n INSERT INTO entities_fts(entities_fts, rowid, name, aliases, category)\n VALUES (\n 'delete',\n old.id,\n old.name,\n COALESCE((SELECT group_concat(value, ' ') FROM json_each(old.aliases_json)), ''),\n old.category\n );\n INSERT INTO entities_fts(rowid, name, aliases, category)\n VALUES (\n new.id,\n new.name,\n COALESCE((SELECT group_concat(value, ' ') FROM json_each(new.aliases_json)), ''),\n new.category\n );\nEND;\n\n-- Recency tracking (replaces entity-recency.json)\nCREATE TABLE IF NOT EXISTS recency (\n entity_name_lower TEXT PRIMARY KEY,\n last_mentioned_at INTEGER NOT NULL,\n mention_count INTEGER DEFAULT 1\n);\n\n-- Write state (replaces last-commit.json and other write state)\nCREATE TABLE IF NOT EXISTS write_state (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL,\n updated_at TEXT DEFAULT (datetime('now'))\n);\n\n-- Content search FTS5 (migrated from vault-search.db)\n-- v11: Added frontmatter column for weighted search (path, title, frontmatter, content)\nCREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(\n path, title, frontmatter, content,\n tokenize='porter'\n);\n\n-- FTS5 build metadata (consolidated from vault-search.db)\nCREATE TABLE IF NOT EXISTS fts_metadata (\n key TEXT PRIMARY KEY,\n value TEXT\n);\n\n-- Vault index cache (for fast startup)\n-- Stores serialized VaultIndex to avoid full rebuild on startup\nCREATE TABLE IF NOT EXISTS vault_index_cache (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n data BLOB NOT NULL,\n built_at INTEGER NOT NULL,\n note_count INTEGER NOT NULL,\n version INTEGER DEFAULT 1\n);\n\n-- Flywheel configuration (replaces .flywheel.json)\nCREATE TABLE IF NOT EXISTS flywheel_config (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL,\n updated_at TEXT DEFAULT (datetime('now'))\n);\n\n-- Vault metrics (v4: growth tracking)\nCREATE TABLE IF NOT EXISTS vault_metrics (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n timestamp INTEGER NOT NULL,\n metric TEXT NOT NULL,\n value REAL NOT NULL\n);\nCREATE INDEX IF NOT EXISTS idx_vault_metrics_ts ON vault_metrics(timestamp);\nCREATE INDEX IF NOT EXISTS idx_vault_metrics_m ON vault_metrics(metric, timestamp);\n\n-- Wikilink feedback (v4: quality tracking, v40: NOCASE note_path)\nCREATE TABLE IF NOT EXISTS wikilink_feedback (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n entity TEXT NOT NULL,\n context TEXT NOT NULL,\n note_path TEXT NOT NULL COLLATE NOCASE,\n correct INTEGER NOT NULL,\n confidence REAL NOT NULL DEFAULT 1.0,\n matched_term TEXT,\n created_at TEXT DEFAULT (datetime('now'))\n);\nCREATE INDEX IF NOT EXISTS idx_wl_feedback_entity ON wikilink_feedback(entity);\nCREATE INDEX IF NOT EXISTS idx_wl_feedback_note_path ON wikilink_feedback(note_path);\n\n-- Wikilink suppressions (v4: auto-suppress false positives)\nCREATE TABLE IF NOT EXISTS wikilink_suppressions (\n entity TEXT PRIMARY KEY,\n false_positive_rate REAL NOT NULL,\n updated_at TEXT DEFAULT (datetime('now'))\n);\n\n-- Wikilink applications tracking (v5: implicit feedback, v38: source provenance)\nCREATE TABLE IF NOT EXISTS wikilink_applications (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n entity TEXT NOT NULL,\n note_path TEXT NOT NULL,\n matched_term TEXT,\n applied_at TEXT DEFAULT (datetime('now')),\n status TEXT DEFAULT 'applied',\n source TEXT NOT NULL DEFAULT 'tool'\n);\n-- v39: note_path uses COLLATE NOCASE so mixed-case paths on case-insensitive\n-- filesystems (Windows NTFS, macOS APFS default) collapse to one row. Prevents\n-- doubled wikilink-application counts when scanner and watcher disagree on casing.\nCREATE UNIQUE INDEX IF NOT EXISTS idx_wl_apps_unique ON wikilink_applications(entity COLLATE NOCASE, note_path COLLATE NOCASE);\n\n-- Index events tracking (v6: index activity history)\nCREATE TABLE IF NOT EXISTS index_events (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n timestamp INTEGER NOT NULL,\n trigger TEXT NOT NULL,\n duration_ms INTEGER NOT NULL,\n success INTEGER NOT NULL DEFAULT 1,\n note_count INTEGER,\n files_changed INTEGER,\n changed_paths TEXT,\n error TEXT,\n steps TEXT\n);\nCREATE INDEX IF NOT EXISTS idx_index_events_ts ON index_events(timestamp);\n\n-- Tool invocation tracking (v7: usage analytics)\nCREATE TABLE IF NOT EXISTS tool_invocations (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n timestamp INTEGER NOT NULL,\n tool_name TEXT NOT NULL,\n session_id TEXT,\n note_paths TEXT,\n duration_ms INTEGER,\n success INTEGER NOT NULL DEFAULT 1,\n response_tokens INTEGER,\n baseline_tokens INTEGER,\n query_context TEXT\n);\nCREATE INDEX IF NOT EXISTS idx_tool_inv_ts ON tool_invocations(timestamp);\nCREATE INDEX IF NOT EXISTS idx_tool_inv_tool ON tool_invocations(tool_name, timestamp);\nCREATE INDEX IF NOT EXISTS idx_tool_inv_session ON tool_invocations(session_id, timestamp);\n\n-- Graph topology snapshots (v8: structural evolution)\nCREATE TABLE IF NOT EXISTS graph_snapshots (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n timestamp INTEGER NOT NULL,\n metric TEXT NOT NULL,\n value REAL NOT NULL,\n details TEXT\n);\nCREATE INDEX IF NOT EXISTS idx_graph_snap_ts ON graph_snapshots(timestamp);\nCREATE INDEX IF NOT EXISTS idx_graph_snap_m ON graph_snapshots(metric, timestamp);\n\n-- Note embeddings for semantic search (v9, v40: NOCASE path)\nCREATE TABLE IF NOT EXISTS note_embeddings (\n path TEXT PRIMARY KEY COLLATE NOCASE,\n embedding BLOB NOT NULL,\n content_hash TEXT NOT NULL,\n model TEXT NOT NULL,\n updated_at INTEGER NOT NULL\n);\n\n-- Entity embeddings for semantic entity search (v10)\nCREATE TABLE IF NOT EXISTS entity_embeddings (\n entity_name TEXT PRIMARY KEY,\n embedding BLOB NOT NULL,\n source_hash TEXT NOT NULL,\n model TEXT NOT NULL,\n updated_at INTEGER NOT NULL\n);\n\n-- Task cache for fast task queries (v12, v40: NOCASE path)\nCREATE TABLE IF NOT EXISTS tasks (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n path TEXT NOT NULL COLLATE NOCASE,\n line INTEGER NOT NULL,\n text TEXT NOT NULL,\n status TEXT NOT NULL,\n raw TEXT NOT NULL,\n context TEXT,\n tags_json TEXT,\n due_date TEXT,\n UNIQUE(path, line)\n);\nCREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);\nCREATE INDEX IF NOT EXISTS idx_tasks_path ON tasks(path);\nCREATE INDEX IF NOT EXISTS idx_tasks_due ON tasks(due_date);\n\n-- Merge dismissals (v13: persistent merge pair suppression)\nCREATE TABLE IF NOT EXISTS merge_dismissals (\n pair_key TEXT PRIMARY KEY,\n source_path TEXT NOT NULL,\n target_path TEXT NOT NULL,\n source_name TEXT NOT NULL,\n target_name TEXT NOT NULL,\n reason TEXT NOT NULL,\n dismissed_at TEXT DEFAULT (datetime('now'))\n);\n\n-- Suggestion events audit log (v15: pipeline observability, v40: NOCASE note_path)\nCREATE TABLE IF NOT EXISTS suggestion_events (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n timestamp INTEGER NOT NULL,\n note_path TEXT NOT NULL COLLATE NOCASE,\n entity TEXT NOT NULL,\n total_score REAL NOT NULL,\n breakdown_json TEXT NOT NULL,\n threshold REAL NOT NULL,\n passed INTEGER NOT NULL,\n strictness TEXT NOT NULL,\n applied INTEGER DEFAULT 0,\n pipeline_event_id INTEGER,\n UNIQUE(timestamp, note_path, entity)\n);\nCREATE INDEX IF NOT EXISTS idx_suggestion_entity ON suggestion_events(entity);\nCREATE INDEX IF NOT EXISTS idx_suggestion_note ON suggestion_events(note_path);\n\n-- Forward-link persistence for diff-based feedback (v16), edge weights (v22), v40: NOCASE note_path\nCREATE TABLE IF NOT EXISTS note_links (\n note_path TEXT NOT NULL COLLATE NOCASE,\n target TEXT NOT NULL,\n weight REAL NOT NULL DEFAULT 1.0,\n weight_updated_at INTEGER,\n PRIMARY KEY (note_path, target)\n);\n\n-- Entity field change audit log (v17, rowid PK since v32)\nCREATE TABLE IF NOT EXISTS entity_changes (\n entity TEXT NOT NULL,\n field TEXT NOT NULL,\n old_value TEXT,\n new_value TEXT,\n changed_at TEXT NOT NULL DEFAULT (datetime('now'))\n);\n\n-- Note tag persistence for diff-based feedback (v18, v40: NOCASE note_path)\nCREATE TABLE IF NOT EXISTS note_tags (\n note_path TEXT NOT NULL COLLATE NOCASE,\n tag TEXT NOT NULL,\n PRIMARY KEY (note_path, tag)\n);\n\n-- Wikilink survival tracking for positive feedback signals (v19, v40: NOCASE note_path)\nCREATE TABLE IF NOT EXISTS note_link_history (\n note_path TEXT NOT NULL COLLATE NOCASE,\n target TEXT NOT NULL,\n first_seen_at TEXT NOT NULL DEFAULT (datetime('now')),\n edits_survived INTEGER NOT NULL DEFAULT 0,\n last_positive_at TEXT,\n PRIMARY KEY (note_path, target)\n);\n\n-- Note move history (v20): records when files are moved/renamed to a different folder, v40: NOCASE paths\nCREATE TABLE IF NOT EXISTS note_moves (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n old_path TEXT NOT NULL COLLATE NOCASE,\n new_path TEXT NOT NULL COLLATE NOCASE,\n moved_at TEXT NOT NULL DEFAULT (datetime('now')),\n old_folder TEXT,\n new_folder TEXT\n);\nCREATE INDEX IF NOT EXISTS idx_note_moves_old_path ON note_moves(old_path);\nCREATE INDEX IF NOT EXISTS idx_note_moves_new_path ON note_moves(new_path);\nCREATE INDEX IF NOT EXISTS idx_note_moves_moved_at ON note_moves(moved_at);\n\n-- Corrections (v24): persistent correction records from user/engine feedback, v40: NOCASE note_path\nCREATE TABLE IF NOT EXISTS corrections (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n entity TEXT,\n note_path TEXT COLLATE NOCASE,\n correction_type TEXT NOT NULL,\n description TEXT NOT NULL,\n source TEXT NOT NULL DEFAULT 'user',\n status TEXT DEFAULT 'pending',\n created_at TEXT DEFAULT (datetime('now')),\n resolved_at TEXT\n);\nCREATE INDEX IF NOT EXISTS idx_corrections_status ON corrections(status);\nCREATE INDEX IF NOT EXISTS idx_corrections_entity ON corrections(entity);\n\n-- Memories (v26): lightweight key-value working memory for agents\nCREATE TABLE IF NOT EXISTS memories (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n key TEXT NOT NULL,\n value TEXT NOT NULL,\n memory_type TEXT NOT NULL,\n entity TEXT,\n entities_json TEXT,\n source_agent_id TEXT,\n source_session_id TEXT,\n confidence REAL NOT NULL DEFAULT 1.0,\n created_at INTEGER NOT NULL,\n updated_at INTEGER NOT NULL,\n accessed_at INTEGER NOT NULL,\n ttl_days INTEGER,\n superseded_by INTEGER REFERENCES memories(id),\n visibility TEXT NOT NULL DEFAULT 'shared'\n);\nCREATE INDEX IF NOT EXISTS idx_memories_key ON memories(key);\nCREATE INDEX IF NOT EXISTS idx_memories_entity ON memories(entity);\nCREATE INDEX IF NOT EXISTS idx_memories_type ON memories(memory_type);\n\nCREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(\n key, value,\n content=memories, content_rowid=id,\n tokenize='porter unicode61'\n);\n\n-- Auto-sync triggers for memories_fts\nCREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN\n INSERT INTO memories_fts(rowid, key, value)\n VALUES (new.id, new.key, new.value);\nEND;\n\nCREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN\n INSERT INTO memories_fts(memories_fts, rowid, key, value)\n VALUES ('delete', old.id, old.key, old.value);\nEND;\n\nCREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN\n INSERT INTO memories_fts(memories_fts, rowid, key, value)\n VALUES ('delete', old.id, old.key, old.value);\n INSERT INTO memories_fts(rowid, key, value)\n VALUES (new.id, new.key, new.value);\nEND;\n\n-- Co-occurrence cache (v27): persist co-occurrence index to avoid full vault scan on restart\nCREATE TABLE IF NOT EXISTS cooccurrence_cache (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n data TEXT NOT NULL,\n built_at INTEGER NOT NULL,\n entity_count INTEGER NOT NULL,\n association_count INTEGER NOT NULL\n);\n\n-- Content hashes (v28): persist watcher content hashes across restarts, v40: NOCASE path\nCREATE TABLE IF NOT EXISTS content_hashes (\n path TEXT PRIMARY KEY COLLATE NOCASE,\n hash TEXT NOT NULL,\n updated_at INTEGER NOT NULL\n);\n\n-- Session summaries (v26): agent session tracking\nCREATE TABLE IF NOT EXISTS session_summaries (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n session_id TEXT NOT NULL UNIQUE,\n summary TEXT NOT NULL,\n topics_json TEXT,\n notes_modified_json TEXT,\n agent_id TEXT,\n started_at INTEGER,\n ended_at INTEGER NOT NULL,\n tool_count INTEGER\n);\n\n-- Retrieval co-occurrence (v30): notes retrieved together build implicit edges, v40: NOCASE notes\nCREATE TABLE IF NOT EXISTS retrieval_cooccurrence (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n note_a TEXT NOT NULL COLLATE NOCASE,\n note_b TEXT NOT NULL COLLATE NOCASE,\n session_id TEXT NOT NULL,\n timestamp INTEGER NOT NULL,\n weight REAL NOT NULL DEFAULT 1.0,\n UNIQUE(note_a, note_b, session_id)\n);\nCREATE INDEX IF NOT EXISTS idx_retcooc_notes ON retrieval_cooccurrence(note_a, note_b);\nCREATE INDEX IF NOT EXISTS idx_retcooc_ts ON retrieval_cooccurrence(timestamp);\n\n-- Deferred proactive linking queue (v31, v40: NOCASE note_path)\nCREATE TABLE IF NOT EXISTS proactive_queue (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n note_path TEXT NOT NULL COLLATE NOCASE,\n entity TEXT NOT NULL,\n score REAL NOT NULL,\n confidence TEXT NOT NULL,\n queued_at INTEGER NOT NULL,\n expires_at INTEGER NOT NULL,\n status TEXT NOT NULL DEFAULT 'pending',\n applied_at INTEGER,\n UNIQUE(note_path, entity)\n);\nCREATE INDEX IF NOT EXISTS idx_pq_status ON proactive_queue(status, expires_at);\n\n-- Performance benchmarks (v33: longitudinal tracking)\nCREATE TABLE IF NOT EXISTS performance_benchmarks (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n timestamp INTEGER NOT NULL,\n version TEXT NOT NULL,\n benchmark TEXT NOT NULL,\n mean_ms REAL NOT NULL,\n p50_ms REAL,\n p95_ms REAL,\n iterations INTEGER NOT NULL DEFAULT 1\n);\nCREATE INDEX IF NOT EXISTS idx_perf_bench_ts ON performance_benchmarks(timestamp);\nCREATE INDEX IF NOT EXISTS idx_perf_bench_name ON performance_benchmarks(benchmark, timestamp);\n\n-- Tool selection feedback (v36: tool selection quality tracking)\nCREATE TABLE IF NOT EXISTS tool_selection_feedback (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n timestamp INTEGER NOT NULL,\n tool_invocation_id INTEGER,\n tool_name TEXT NOT NULL,\n query_context TEXT,\n expected_tool TEXT,\n expected_category TEXT,\n correct INTEGER,\n source TEXT NOT NULL DEFAULT 'explicit',\n rule_id TEXT,\n rule_version INTEGER,\n session_id TEXT,\n created_at TEXT DEFAULT (datetime('now'))\n);\nCREATE INDEX IF NOT EXISTS idx_tsf_tool ON tool_selection_feedback(tool_name);\nCREATE INDEX IF NOT EXISTS idx_tsf_ts ON tool_selection_feedback(timestamp);\n\n-- Prospect ledger (v37): day-grain sightings for pre-entity pattern accumulation, v40: NOCASE note_path\nCREATE TABLE IF NOT EXISTS prospect_ledger (\n term TEXT NOT NULL,\n display_name TEXT NOT NULL,\n note_path TEXT NOT NULL COLLATE NOCASE,\n seen_day TEXT NOT NULL,\n source TEXT NOT NULL,\n pattern TEXT,\n confidence TEXT NOT NULL DEFAULT 'low',\n backlink_count INTEGER DEFAULT 0,\n score REAL DEFAULT 0,\n first_seen_at INTEGER NOT NULL,\n last_seen_at INTEGER NOT NULL,\n sighting_count INTEGER NOT NULL DEFAULT 1,\n PRIMARY KEY (term, note_path, seen_day)\n);\nCREATE INDEX IF NOT EXISTS idx_prospect_term ON prospect_ledger(term);\nCREATE INDEX IF NOT EXISTS idx_prospect_last_seen ON prospect_ledger(last_seen_at);\n\n-- Prospect summary (v37): materialized aggregate for fast scoring\nCREATE TABLE IF NOT EXISTS prospect_summary (\n term TEXT PRIMARY KEY,\n display_name TEXT NOT NULL,\n note_count INTEGER NOT NULL DEFAULT 0,\n day_count INTEGER NOT NULL DEFAULT 0,\n total_sightings INTEGER NOT NULL DEFAULT 0,\n backlink_max INTEGER NOT NULL DEFAULT 0,\n cooccurring_entities TEXT,\n best_source TEXT NOT NULL DEFAULT 'implicit',\n best_confidence TEXT NOT NULL DEFAULT 'low',\n best_score REAL NOT NULL DEFAULT 0,\n first_seen_at INTEGER NOT NULL,\n last_seen_at INTEGER NOT NULL,\n promotion_score REAL NOT NULL DEFAULT 0,\n promoted_at INTEGER,\n updated_at INTEGER NOT NULL\n);\nCREATE INDEX IF NOT EXISTS idx_prospect_summary_score ON prospect_summary(promotion_score DESC);\n\n";
14
14
  //# sourceMappingURL=schema.d.ts.map
package/dist/schema.js CHANGED
@@ -8,7 +8,7 @@
8
8
  // Constants
9
9
  // =============================================================================
10
10
  /** Current schema version - bump when schema changes */
11
- export const SCHEMA_VERSION = 38;
11
+ export const SCHEMA_VERSION = 40;
12
12
  /** State database filename */
13
13
  export const STATE_DB_FILENAME = 'state.db';
14
14
  /** Directory for flywheel state */
@@ -35,7 +35,7 @@ CREATE TABLE IF NOT EXISTS entities (
35
35
  id INTEGER PRIMARY KEY,
36
36
  name TEXT NOT NULL,
37
37
  name_lower TEXT NOT NULL,
38
- path TEXT NOT NULL,
38
+ path TEXT NOT NULL COLLATE NOCASE,
39
39
  category TEXT NOT NULL,
40
40
  aliases_json TEXT,
41
41
  hub_score INTEGER DEFAULT 0,
@@ -145,12 +145,12 @@ CREATE TABLE IF NOT EXISTS vault_metrics (
145
145
  CREATE INDEX IF NOT EXISTS idx_vault_metrics_ts ON vault_metrics(timestamp);
146
146
  CREATE INDEX IF NOT EXISTS idx_vault_metrics_m ON vault_metrics(metric, timestamp);
147
147
 
148
- -- Wikilink feedback (v4: quality tracking)
148
+ -- Wikilink feedback (v4: quality tracking, v40: NOCASE note_path)
149
149
  CREATE TABLE IF NOT EXISTS wikilink_feedback (
150
150
  id INTEGER PRIMARY KEY AUTOINCREMENT,
151
151
  entity TEXT NOT NULL,
152
152
  context TEXT NOT NULL,
153
- note_path TEXT NOT NULL,
153
+ note_path TEXT NOT NULL COLLATE NOCASE,
154
154
  correct INTEGER NOT NULL,
155
155
  confidence REAL NOT NULL DEFAULT 1.0,
156
156
  matched_term TEXT,
@@ -176,7 +176,10 @@ CREATE TABLE IF NOT EXISTS wikilink_applications (
176
176
  status TEXT DEFAULT 'applied',
177
177
  source TEXT NOT NULL DEFAULT 'tool'
178
178
  );
179
- CREATE UNIQUE INDEX IF NOT EXISTS idx_wl_apps_unique ON wikilink_applications(entity COLLATE NOCASE, note_path);
179
+ -- v39: note_path uses COLLATE NOCASE so mixed-case paths on case-insensitive
180
+ -- filesystems (Windows NTFS, macOS APFS default) collapse to one row. Prevents
181
+ -- doubled wikilink-application counts when scanner and watcher disagree on casing.
182
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_wl_apps_unique ON wikilink_applications(entity COLLATE NOCASE, note_path COLLATE NOCASE);
180
183
 
181
184
  -- Index events tracking (v6: index activity history)
182
185
  CREATE TABLE IF NOT EXISTS index_events (
@@ -221,9 +224,9 @@ CREATE TABLE IF NOT EXISTS graph_snapshots (
221
224
  CREATE INDEX IF NOT EXISTS idx_graph_snap_ts ON graph_snapshots(timestamp);
222
225
  CREATE INDEX IF NOT EXISTS idx_graph_snap_m ON graph_snapshots(metric, timestamp);
223
226
 
224
- -- Note embeddings for semantic search (v9)
227
+ -- Note embeddings for semantic search (v9, v40: NOCASE path)
225
228
  CREATE TABLE IF NOT EXISTS note_embeddings (
226
- path TEXT PRIMARY KEY,
229
+ path TEXT PRIMARY KEY COLLATE NOCASE,
227
230
  embedding BLOB NOT NULL,
228
231
  content_hash TEXT NOT NULL,
229
232
  model TEXT NOT NULL,
@@ -239,10 +242,10 @@ CREATE TABLE IF NOT EXISTS entity_embeddings (
239
242
  updated_at INTEGER NOT NULL
240
243
  );
241
244
 
242
- -- Task cache for fast task queries (v12)
245
+ -- Task cache for fast task queries (v12, v40: NOCASE path)
243
246
  CREATE TABLE IF NOT EXISTS tasks (
244
247
  id INTEGER PRIMARY KEY AUTOINCREMENT,
245
- path TEXT NOT NULL,
248
+ path TEXT NOT NULL COLLATE NOCASE,
246
249
  line INTEGER NOT NULL,
247
250
  text TEXT NOT NULL,
248
251
  status TEXT NOT NULL,
@@ -267,11 +270,11 @@ CREATE TABLE IF NOT EXISTS merge_dismissals (
267
270
  dismissed_at TEXT DEFAULT (datetime('now'))
268
271
  );
269
272
 
270
- -- Suggestion events audit log (v15: pipeline observability)
273
+ -- Suggestion events audit log (v15: pipeline observability, v40: NOCASE note_path)
271
274
  CREATE TABLE IF NOT EXISTS suggestion_events (
272
275
  id INTEGER PRIMARY KEY AUTOINCREMENT,
273
276
  timestamp INTEGER NOT NULL,
274
- note_path TEXT NOT NULL,
277
+ note_path TEXT NOT NULL COLLATE NOCASE,
275
278
  entity TEXT NOT NULL,
276
279
  total_score REAL NOT NULL,
277
280
  breakdown_json TEXT NOT NULL,
@@ -285,9 +288,9 @@ CREATE TABLE IF NOT EXISTS suggestion_events (
285
288
  CREATE INDEX IF NOT EXISTS idx_suggestion_entity ON suggestion_events(entity);
286
289
  CREATE INDEX IF NOT EXISTS idx_suggestion_note ON suggestion_events(note_path);
287
290
 
288
- -- Forward-link persistence for diff-based feedback (v16), edge weights (v22)
291
+ -- Forward-link persistence for diff-based feedback (v16), edge weights (v22), v40: NOCASE note_path
289
292
  CREATE TABLE IF NOT EXISTS note_links (
290
- note_path TEXT NOT NULL,
293
+ note_path TEXT NOT NULL COLLATE NOCASE,
291
294
  target TEXT NOT NULL,
292
295
  weight REAL NOT NULL DEFAULT 1.0,
293
296
  weight_updated_at INTEGER,
@@ -303,16 +306,16 @@ CREATE TABLE IF NOT EXISTS entity_changes (
303
306
  changed_at TEXT NOT NULL DEFAULT (datetime('now'))
304
307
  );
305
308
 
306
- -- Note tag persistence for diff-based feedback (v18)
309
+ -- Note tag persistence for diff-based feedback (v18, v40: NOCASE note_path)
307
310
  CREATE TABLE IF NOT EXISTS note_tags (
308
- note_path TEXT NOT NULL,
311
+ note_path TEXT NOT NULL COLLATE NOCASE,
309
312
  tag TEXT NOT NULL,
310
313
  PRIMARY KEY (note_path, tag)
311
314
  );
312
315
 
313
- -- Wikilink survival tracking for positive feedback signals (v19)
316
+ -- Wikilink survival tracking for positive feedback signals (v19, v40: NOCASE note_path)
314
317
  CREATE TABLE IF NOT EXISTS note_link_history (
315
- note_path TEXT NOT NULL,
318
+ note_path TEXT NOT NULL COLLATE NOCASE,
316
319
  target TEXT NOT NULL,
317
320
  first_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
318
321
  edits_survived INTEGER NOT NULL DEFAULT 0,
@@ -320,11 +323,11 @@ CREATE TABLE IF NOT EXISTS note_link_history (
320
323
  PRIMARY KEY (note_path, target)
321
324
  );
322
325
 
323
- -- Note move history (v20): records when files are moved/renamed to a different folder
326
+ -- Note move history (v20): records when files are moved/renamed to a different folder, v40: NOCASE paths
324
327
  CREATE TABLE IF NOT EXISTS note_moves (
325
328
  id INTEGER PRIMARY KEY AUTOINCREMENT,
326
- old_path TEXT NOT NULL,
327
- new_path TEXT NOT NULL,
329
+ old_path TEXT NOT NULL COLLATE NOCASE,
330
+ new_path TEXT NOT NULL COLLATE NOCASE,
328
331
  moved_at TEXT NOT NULL DEFAULT (datetime('now')),
329
332
  old_folder TEXT,
330
333
  new_folder TEXT
@@ -333,11 +336,11 @@ CREATE INDEX IF NOT EXISTS idx_note_moves_old_path ON note_moves(old_path);
333
336
  CREATE INDEX IF NOT EXISTS idx_note_moves_new_path ON note_moves(new_path);
334
337
  CREATE INDEX IF NOT EXISTS idx_note_moves_moved_at ON note_moves(moved_at);
335
338
 
336
- -- Corrections (v24): persistent correction records from user/engine feedback
339
+ -- Corrections (v24): persistent correction records from user/engine feedback, v40: NOCASE note_path
337
340
  CREATE TABLE IF NOT EXISTS corrections (
338
341
  id INTEGER PRIMARY KEY AUTOINCREMENT,
339
342
  entity TEXT,
340
- note_path TEXT,
343
+ note_path TEXT COLLATE NOCASE,
341
344
  correction_type TEXT NOT NULL,
342
345
  description TEXT NOT NULL,
343
346
  source TEXT NOT NULL DEFAULT 'user',
@@ -403,9 +406,9 @@ CREATE TABLE IF NOT EXISTS cooccurrence_cache (
403
406
  association_count INTEGER NOT NULL
404
407
  );
405
408
 
406
- -- Content hashes (v28): persist watcher content hashes across restarts
409
+ -- Content hashes (v28): persist watcher content hashes across restarts, v40: NOCASE path
407
410
  CREATE TABLE IF NOT EXISTS content_hashes (
408
- path TEXT PRIMARY KEY,
411
+ path TEXT PRIMARY KEY COLLATE NOCASE,
409
412
  hash TEXT NOT NULL,
410
413
  updated_at INTEGER NOT NULL
411
414
  );
@@ -423,11 +426,11 @@ CREATE TABLE IF NOT EXISTS session_summaries (
423
426
  tool_count INTEGER
424
427
  );
425
428
 
426
- -- Retrieval co-occurrence (v30): notes retrieved together build implicit edges
429
+ -- Retrieval co-occurrence (v30): notes retrieved together build implicit edges, v40: NOCASE notes
427
430
  CREATE TABLE IF NOT EXISTS retrieval_cooccurrence (
428
431
  id INTEGER PRIMARY KEY AUTOINCREMENT,
429
- note_a TEXT NOT NULL,
430
- note_b TEXT NOT NULL,
432
+ note_a TEXT NOT NULL COLLATE NOCASE,
433
+ note_b TEXT NOT NULL COLLATE NOCASE,
431
434
  session_id TEXT NOT NULL,
432
435
  timestamp INTEGER NOT NULL,
433
436
  weight REAL NOT NULL DEFAULT 1.0,
@@ -436,10 +439,10 @@ CREATE TABLE IF NOT EXISTS retrieval_cooccurrence (
436
439
  CREATE INDEX IF NOT EXISTS idx_retcooc_notes ON retrieval_cooccurrence(note_a, note_b);
437
440
  CREATE INDEX IF NOT EXISTS idx_retcooc_ts ON retrieval_cooccurrence(timestamp);
438
441
 
439
- -- Deferred proactive linking queue (v31)
442
+ -- Deferred proactive linking queue (v31, v40: NOCASE note_path)
440
443
  CREATE TABLE IF NOT EXISTS proactive_queue (
441
444
  id INTEGER PRIMARY KEY AUTOINCREMENT,
442
- note_path TEXT NOT NULL,
445
+ note_path TEXT NOT NULL COLLATE NOCASE,
443
446
  entity TEXT NOT NULL,
444
447
  score REAL NOT NULL,
445
448
  confidence TEXT NOT NULL,
@@ -484,11 +487,11 @@ CREATE TABLE IF NOT EXISTS tool_selection_feedback (
484
487
  CREATE INDEX IF NOT EXISTS idx_tsf_tool ON tool_selection_feedback(tool_name);
485
488
  CREATE INDEX IF NOT EXISTS idx_tsf_ts ON tool_selection_feedback(timestamp);
486
489
 
487
- -- Prospect ledger (v37): day-grain sightings for pre-entity pattern accumulation
490
+ -- Prospect ledger (v37): day-grain sightings for pre-entity pattern accumulation, v40: NOCASE note_path
488
491
  CREATE TABLE IF NOT EXISTS prospect_ledger (
489
492
  term TEXT NOT NULL,
490
493
  display_name TEXT NOT NULL,
491
- note_path TEXT NOT NULL,
494
+ note_path TEXT NOT NULL COLLATE NOCASE,
492
495
  seen_day TEXT NOT NULL,
493
496
  source TEXT NOT NULL,
494
497
  pattern TEXT,
package/dist/sqlite.d.ts CHANGED
@@ -13,7 +13,7 @@ import Database from 'better-sqlite3';
13
13
  import type { Statement, Transaction } from 'better-sqlite3';
14
14
  import type { EntityCategory, EntityWithAliases, EntityIndex } from './types.js';
15
15
  export { SCHEMA_VERSION, STATE_DB_FILENAME, FLYWHEEL_DIR, SCHEMA_SQL } from './schema.js';
16
- export { getStateDbPath, initSchema, deleteStateDbFiles, backupStateDb, preserveCorruptedDb } from './migrations.js';
16
+ export { getStateDbPath, initSchema, migrateV40, deleteStateDbFiles, backupStateDb, preserveCorruptedDb } from './migrations.js';
17
17
  export { BACKUP_ROTATION_COUNT, SALVAGE_TABLES, rotateBackupFiles, safeBackupAsync, checkDbIntegrity, salvageFeedbackTables, attemptSalvage, } from './migrations.js';
18
18
  export { searchEntities, searchEntitiesPrefix, getEntityByName, getAllEntitiesFromDb, getEntityIndexFromDb, getEntitiesByAlias, recordEntityMention, getEntityRecency, getAllRecency, setWriteState, getWriteState, deleteWriteState, setFlywheelConfig, getFlywheelConfig, getAllFlywheelConfig, deleteFlywheelConfig, saveFlywheelConfigToDb, loadFlywheelConfigFromDb, recordMergeDismissal, getDismissedMergePairs, getStateDbMetadata, isEntityDataStale, escapeFts5Query, rebuildEntitiesFts, stateDbExists, deleteStateDb, saveVaultIndexCache, loadVaultIndexCache, getVaultIndexCacheInfo, clearVaultIndexCache, isVaultIndexCacheValid, loadContentHashes, saveContentHashBatch, renameContentHash, } from './queries.js';
19
19
  export type { FlywheelConfigRow, VaultIndexCacheData, VaultIndexCacheInfo } from './queries.js';
package/dist/sqlite.js CHANGED
@@ -14,7 +14,7 @@ import * as fs from 'fs';
14
14
  // Re-export constants from schema
15
15
  export { SCHEMA_VERSION, STATE_DB_FILENAME, FLYWHEEL_DIR, SCHEMA_SQL } from './schema.js';
16
16
  // Re-export migrations
17
- export { getStateDbPath, initSchema, deleteStateDbFiles, backupStateDb, preserveCorruptedDb } from './migrations.js';
17
+ export { getStateDbPath, initSchema, migrateV40, deleteStateDbFiles, backupStateDb, preserveCorruptedDb } from './migrations.js';
18
18
  // Re-export backup & recovery
19
19
  export { BACKUP_ROTATION_COUNT, SALVAGE_TABLES, rotateBackupFiles, safeBackupAsync, checkDbIntegrity, salvageFeedbackTables, attemptSalvage, } from './migrations.js';
20
20
  // Re-export all query functions
@@ -22,6 +22,49 @@ export { searchEntities, searchEntitiesPrefix, getEntityByName, getAllEntitiesFr
22
22
  // Import for use in openStateDb
23
23
  import { getStateDbPath, initSchema, deleteStateDbFiles, preserveCorruptedDb, attemptSalvage } from './migrations.js';
24
24
  // =============================================================================
25
+ // Pre-v40 backup hook
26
+ // =============================================================================
27
+ /**
28
+ * Snapshot the state DB to `<dbPath>.pre-v40.backup` before the v40 migration
29
+ * runs. Synchronous (uses VACUUM INTO) so it slots into openStateDb's existing
30
+ * sync flow without cascading async to dozens of callers.
31
+ *
32
+ * Skipped if:
33
+ * - schema_version table doesn't exist (fresh DB, nothing to back up)
34
+ * - currentVersion >= 40 (already migrated, hook is a no-op)
35
+ * - quick_check fails (don't overwrite a good backup with a corrupt DB)
36
+ *
37
+ * Failures are logged but never throw — the migration may still succeed.
38
+ */
39
+ function runPreV40BackupHook(db, dbPath) {
40
+ try {
41
+ const schemaVersionTable = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='schema_version'`).get();
42
+ if (!schemaVersionTable)
43
+ return;
44
+ const row = db.prepare('SELECT MAX(version) AS v FROM schema_version').get();
45
+ const currentVersion = row?.v ?? 0;
46
+ if (currentVersion >= 40)
47
+ return;
48
+ const quickCheck = db.pragma('quick_check');
49
+ const firstValue = quickCheck.length > 0 ? Object.values(quickCheck[0])[0] : 'no result';
50
+ if (firstValue !== 'ok') {
51
+ console.error(`[vault-core] Pre-v40 backup skipped: quick_check returned "${firstValue}" — DB may be corrupt`);
52
+ return;
53
+ }
54
+ const backupPath = `${dbPath}.pre-v40.backup`;
55
+ // VACUUM INTO refuses to overwrite an existing file. Clear any prior snapshot first.
56
+ if (fs.existsSync(backupPath)) {
57
+ fs.unlinkSync(backupPath);
58
+ }
59
+ const escaped = backupPath.replace(/'/g, "''");
60
+ db.exec(`VACUUM INTO '${escaped}'`);
61
+ console.error(`[vault-core] Pre-v40 backup created at ${backupPath}`);
62
+ }
63
+ catch (err) {
64
+ console.error(`[vault-core] Pre-v40 backup failed: ${err instanceof Error ? err.message : err}`);
65
+ }
66
+ }
67
+ // =============================================================================
25
68
  // Factory
26
69
  // =============================================================================
27
70
  /**
@@ -49,6 +92,15 @@ export function openStateDb(vaultPath) {
49
92
  let db;
50
93
  try {
51
94
  db = new Database(dbPath);
95
+ // Pre-v40 backup hook: before initSchema runs migrations, snapshot the
96
+ // current state.db so users can recover if the v40 COLLATE NOCASE rebuild
97
+ // picks an unexpected row in a tie. Synchronous VACUUM INTO is WAL-safe.
98
+ // Skipped on fresh DBs (no schema_version table yet) and on already-migrated
99
+ // databases (currentVersion >= 40). Quick_check guards against backing up
100
+ // a corrupt DB on top of a previously-good backup.
101
+ if (!isNewDb) {
102
+ runPreV40BackupHook(db, dbPath);
103
+ }
52
104
  initSchema(db);
53
105
  // Enable incremental auto_vacuum on existing databases (one-time cost).
54
106
  // New DBs get it from initSchema before tables are created, but existing
package/dist/wikilinks.js CHANGED
@@ -816,7 +816,10 @@ export function resolveAliasWikilinks(content, entities, options = {}) {
816
816
  const DEFAULT_IMPLICIT_CONFIG = {
817
817
  detectImplicit: false,
818
818
  implicitPatterns: ['proper-nouns', 'quoted-terms'],
819
- excludePatterns: ['^The ', '^A ', '^An ', '^This ', '^That ', '^These ', '^Those '],
819
+ excludePatterns: [
820
+ '^The ', '^A ', '^An ', '^This ', '^That ', '^These ', '^Those ',
821
+ '^v?\\d+(?:\\.\\d+){1,3}(?:[-.][a-zA-Z0-9]+)?$',
822
+ ],
820
823
  minEntityLength: 3,
821
824
  };
822
825
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velvetmonkey/vault-core",
3
- "version": "2.7.0",
3
+ "version": "2.8.1",
4
4
  "description": "Shared vault utilities for Flywheel ecosystem (entity scanning, wikilinks, protected zones)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",