gnosys 5.12.0 → 5.12.3

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 (78) hide show
  1. package/dist/cli.js +48 -7
  2. package/dist/index.js +199 -14
  3. package/dist/lib/addCommand.js +0 -1
  4. package/dist/lib/archive.js +10 -6
  5. package/dist/lib/askCommand.js +1 -1
  6. package/dist/lib/attachCommand.d.ts +17 -0
  7. package/dist/lib/attachCommand.js +66 -0
  8. package/dist/lib/attachments.d.ts +43 -2
  9. package/dist/lib/attachments.js +81 -2
  10. package/dist/lib/chat/choose.js +2 -2
  11. package/dist/lib/clientReadOverlay.js +3 -0
  12. package/dist/lib/config.d.ts +1 -48
  13. package/dist/lib/configCommand.js +2 -2
  14. package/dist/lib/db.d.ts +16 -1
  15. package/dist/lib/db.js +248 -133
  16. package/dist/lib/dbWrite.d.ts +1 -1
  17. package/dist/lib/dearchiveCommand.js +1 -1
  18. package/dist/lib/docxExtract.js +1 -1
  19. package/dist/lib/dream.d.ts +8 -0
  20. package/dist/lib/dream.js +35 -1
  21. package/dist/lib/dreamLogCommand.js +1 -1
  22. package/dist/lib/dreamRunLog.d.ts +1 -1
  23. package/dist/lib/dreamRunLog.js +26 -4
  24. package/dist/lib/embeddings.js +0 -3
  25. package/dist/lib/exportProject.d.ts +3 -2
  26. package/dist/lib/exportProject.js +2 -1
  27. package/dist/lib/federated.js +1 -1
  28. package/dist/lib/ftsQuery.d.ts +25 -0
  29. package/dist/lib/ftsQuery.js +42 -0
  30. package/dist/lib/hybridSearch.d.ts +9 -0
  31. package/dist/lib/hybridSearch.js +14 -0
  32. package/dist/lib/hybridSearchCommand.js +6 -1
  33. package/dist/lib/importProject.js +2 -1
  34. package/dist/lib/llm.js +1 -1
  35. package/dist/lib/lock.d.ts +1 -1
  36. package/dist/lib/lock.js +5 -3
  37. package/dist/lib/migrate.js +0 -1
  38. package/dist/lib/multimodalIngest.js +1 -1
  39. package/dist/lib/platform.d.ts +0 -6
  40. package/dist/lib/platform.js +0 -28
  41. package/dist/lib/readCommand.js +11 -10
  42. package/dist/lib/remoteWizard.d.ts +1 -1
  43. package/dist/lib/remoteWizard.js +4 -4
  44. package/dist/lib/resolver.d.ts +17 -0
  45. package/dist/lib/resolver.js +43 -17
  46. package/dist/lib/rulesGen.d.ts +8 -0
  47. package/dist/lib/rulesGen.js +16 -0
  48. package/dist/lib/search.d.ts +0 -2
  49. package/dist/lib/search.js +33 -27
  50. package/dist/lib/semanticSearchCommand.js +1 -1
  51. package/dist/lib/setup/sections/providers.js +56 -4
  52. package/dist/lib/setup/sections/routing.js +42 -5
  53. package/dist/lib/setup/sections/taskRoutingEditor.d.ts +1 -5
  54. package/dist/lib/setup/sections/taskRoutingEditor.js +0 -10
  55. package/dist/lib/setup/ui/header.js +0 -1
  56. package/dist/lib/setup/ui/status.d.ts +0 -1
  57. package/dist/lib/setup/ui/status.js +0 -2
  58. package/dist/lib/setup.d.ts +0 -15
  59. package/dist/lib/setup.js +13 -158
  60. package/dist/lib/staleCommand.js +2 -2
  61. package/dist/lib/syncClient.d.ts +0 -6
  62. package/dist/lib/syncClient.js +36 -14
  63. package/dist/lib/syncDoctorCommand.js +2 -2
  64. package/dist/lib/syncIngest.d.ts +11 -0
  65. package/dist/lib/syncIngest.js +24 -1
  66. package/dist/lib/syncIngestStartup.js +2 -2
  67. package/dist/lib/syncSnapshot.d.ts +2 -0
  68. package/dist/lib/syncSnapshot.js +4 -0
  69. package/dist/lib/syncStaging.d.ts +0 -2
  70. package/dist/lib/syncStaging.js +0 -2
  71. package/dist/lib/updateCommand.js +1 -1
  72. package/dist/lib/webBuildCommand.js +1 -1
  73. package/dist/lib/webIndex.js +0 -1
  74. package/dist/lib/webIngestCommand.js +1 -1
  75. package/dist/sandbox/client.js +1 -1
  76. package/dist/sandbox/manager.js +1 -14
  77. package/dist/sandbox/server.js +3 -5
  78. package/package.json +5 -2
package/dist/lib/db.js CHANGED
@@ -8,7 +8,6 @@
8
8
  * + projects table (v3.0) for project identity registry.
9
9
  */
10
10
  // Dynamic import — gracefully handles missing native module
11
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
12
11
  let Database = null;
13
12
  try {
14
13
  Database = (await import("better-sqlite3")).default;
@@ -22,6 +21,7 @@ import { enableWAL } from "./lock.js";
22
21
  import { getGnosysHome as getGnosysHomeImpl, getCentralDbPath as getCentralDbPathImpl } from "./paths.js";
23
22
  import { readMachineConfig } from "./machineConfig.js";
24
23
  import { logError } from "./log.js";
24
+ import { ftsTerms, ftsAndQuery, ftsOrQuery } from "./ftsQuery.js";
25
25
  import { ulid } from "ulidx";
26
26
  // ─── Schema ─────────────────────────────────────────────────────────────
27
27
  const SCHEMA_VERSION = 5;
@@ -51,6 +51,9 @@ CREATE TABLE IF NOT EXISTS memories (
51
51
  source_file TEXT,
52
52
  source_page TEXT,
53
53
  source_timerange TEXT,
54
+ attachment_data BLOB,
55
+ attachment_mime TEXT,
56
+ attachment_name TEXT,
54
57
  project_id TEXT,
55
58
  scope TEXT DEFAULT 'project' CHECK(scope IN ('project','user','global'))
56
59
  );
@@ -243,12 +246,27 @@ const MEMORY_COLUMNS = new Set([
243
246
  "status", "tier", "supersedes", "superseded_by", "last_reinforced",
244
247
  "created", "modified", "embedding", "source_path",
245
248
  "source_file", "source_page", "source_timerange",
249
+ "attachment_data", "attachment_mime", "attachment_name",
246
250
  "project_id", "scope",
247
251
  ]);
248
252
  const PROJECT_COLUMNS = new Set([
249
253
  "name", "working_directory", "root_id", "rel_path", "user",
250
254
  "agent_rules_target", "obsidian_vault", "created", "modified",
251
255
  ]);
256
+ /**
257
+ * v5.12.x perf: full DbMemory shape with the two BLOB columns projected as
258
+ * NULL. List-style reads (recall, federation, list) never consume embedding
259
+ * or attachment bytes. Measured win on embedding-only rows is modest (~4%
260
+ * plus avoided Buffer churn), but attachments are the real reason: a single
261
+ * ~10MB gnosys_attach blob would otherwise be hydrated on EVERY list call.
262
+ * Blob consumers use getAllEmbeddings/getEmbedding/getMemoryAttachment, and
263
+ * getAllMemories/getMemoriesByProject still return full rows (remote sync
264
+ * and project export push them verbatim).
265
+ */
266
+ const LEAN_MEMORY_PROJECTION = [
267
+ "id", ...[...MEMORY_COLUMNS].filter((c) => c !== "embedding" && c !== "attachment_data"),
268
+ "NULL AS embedding", "NULL AS attachment_data",
269
+ ].join(", ");
252
270
  // ─── FNV-1a hash (same as embeddings.ts) ────────────────────────────────
253
271
  function fnv1a(str) {
254
272
  let hash = 0x811c9dc5;
@@ -260,8 +278,10 @@ function fnv1a(str) {
260
278
  }
261
279
  // ─── GnosysDB Class ─────────────────────────────────────────────────────
262
280
  export class GnosysDB {
263
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
264
281
  db = null;
282
+ /** v5.12.x perf: prepared-statement cache, keyed by SQL. Invalidated on
283
+ * reopen()/close() — statements are bound to their connection handle. */
284
+ stmtCache = new Map();
265
285
  storePath;
266
286
  available = false;
267
287
  dbFilePath;
@@ -381,7 +401,8 @@ export class GnosysDB {
381
401
  try {
382
402
  fs.mkdirSync(storePath, { recursive: true, mode: 0o700 });
383
403
  this.db = new Database(this.dbFilePath);
384
- enableWAL(this.db);
404
+ // Longer busy timeout for network shares (10s)
405
+ enableWAL(this.db, 10000);
385
406
  try {
386
407
  fs.chmodSync(storePath, 0o700);
387
408
  fs.chmodSync(this.dbFilePath, 0o600);
@@ -396,13 +417,11 @@ export class GnosysDB {
396
417
  // best-effort (Windows / network FS)
397
418
  }
398
419
  this.db.pragma("foreign_keys = ON");
399
- // Longer busy timeout for network shares (10s)
400
- this.db.pragma("busy_timeout = 10000");
401
420
  this.applySchema();
402
421
  this.available = true;
403
422
  return; // Success
404
423
  }
405
- catch (err) {
424
+ catch (_err) {
406
425
  this.db = null;
407
426
  if (attempt < maxRetries) {
408
427
  // Synchronous delay for constructor (network share retry)
@@ -471,6 +490,7 @@ export class GnosysDB {
471
490
  * file handles after a WAL checkpoint or remount.
472
491
  */
473
492
  reopen() {
493
+ this.stmtCache.clear();
474
494
  try {
475
495
  this.db?.close();
476
496
  }
@@ -483,9 +503,19 @@ export class GnosysDB {
483
503
  return;
484
504
  try {
485
505
  this.db = new Database(this.dbFilePath);
486
- enableWAL(this.db);
506
+ // Longer busy timeout for network shares (10s)
507
+ enableWAL(this.db, 10000);
487
508
  this.db.pragma("foreign_keys = ON");
488
- this.db.pragma("busy_timeout = 10000");
509
+ // v5.12.1: heal FTS triggers on recovery. updateMemory/deleteMemory may
510
+ // drop a trigger in their inconsistency fallback; recreating here
511
+ // (idempotent CREATE TRIGGER IF NOT EXISTS) means recovery restores
512
+ // them instead of waiting for the next process start.
513
+ try {
514
+ this.db.exec(FTS_TRIGGERS_SQL);
515
+ }
516
+ catch {
517
+ // non-fatal — applySchema() heals at next open
518
+ }
489
519
  this.available = true;
490
520
  }
491
521
  catch {
@@ -508,15 +538,28 @@ export class GnosysDB {
508
538
  * Read methods are also wrapped because reads against stale pages can
509
539
  * surface the same error.
510
540
  */
541
+ /**
542
+ * Prepare-with-cache for fixed-SQL hot paths. Dynamic SQL (e.g. the
543
+ * field-built UPDATE in updateMemory) must keep using db.prepare directly
544
+ * so the cache stays bounded. Cache is invalidated on reopen()/close().
545
+ */
546
+ prep(sql) {
547
+ let stmt = this.stmtCache.get(sql);
548
+ if (!stmt) {
549
+ stmt = this.db.prepare(sql);
550
+ this.stmtCache.set(sql, stmt);
551
+ }
552
+ return stmt;
553
+ }
511
554
  withRecovery(fn) {
512
555
  try {
513
556
  return fn();
514
557
  }
515
558
  catch (err) {
516
- const errAny = err;
517
- const isCorrupt = errAny?.code === "SQLITE_CORRUPT" ||
518
- /database disk image is malformed/i.test(errAny?.message ?? "");
519
- if (!isCorrupt)
559
+ // v5.12.1: use the shared corruption detector — it also matches
560
+ // SQLITE_NOTADB ("file is not a database"), which surfaces on network
561
+ // shares when a sync layer swaps the file under a live handle.
562
+ if (!GnosysDB.isCorruptionError(err))
520
563
  throw err;
521
564
  // One-shot recovery: reopen and retry. If the reopen itself fails or
522
565
  // the retry surfaces the same error, that's a real corruption case —
@@ -687,6 +730,29 @@ export class GnosysDB {
687
730
  }
688
731
  }
689
732
  if (fromVersion < 5) {
733
+ // v4 → v5 (v5.12): inline binary attachments carried in the memory row.
734
+ // Additive columns only — existing rows get NULLs. These ride the same
735
+ // row-copy sync path as `embedding`, so attachments travel machine to
736
+ // machine for free.
737
+ try {
738
+ this.db.exec("ALTER TABLE memories ADD COLUMN attachment_data BLOB");
739
+ }
740
+ catch {
741
+ // Column already exists — fine
742
+ }
743
+ try {
744
+ this.db.exec("ALTER TABLE memories ADD COLUMN attachment_mime TEXT");
745
+ }
746
+ catch {
747
+ // Column already exists — fine
748
+ }
749
+ try {
750
+ this.db.exec("ALTER TABLE memories ADD COLUMN attachment_name TEXT");
751
+ }
752
+ catch {
753
+ // Column already exists — fine
754
+ }
755
+ // Sync staging / multi-machine ledger tables (from network-mcp work on feat).
690
756
  try {
691
757
  this.db.exec(`
692
758
  CREATE TABLE IF NOT EXISTS sync_staging_ledger (
@@ -749,23 +815,26 @@ export class GnosysDB {
749
815
  // ─── Memory CRUD ────────────────────────────────────────────────────
750
816
  insertMemory(mem) {
751
817
  return this.withRecovery(() => {
752
- const stmt = this.db.prepare(`
818
+ const stmt = this.prep(`
753
819
  INSERT OR REPLACE INTO memories
754
820
  (id, title, category, content, summary, tags, relevance, author, authority,
755
821
  confidence, reinforcement_count, content_hash, status, tier, supersedes,
756
822
  superseded_by, last_reinforced, created, modified, embedding, source_path,
757
823
  source_file, source_page, source_timerange,
824
+ attachment_data, attachment_mime, attachment_name,
758
825
  project_id, scope)
759
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
826
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
760
827
  `);
761
- stmt.run(mem.id, mem.title, mem.category, mem.content, mem.summary || null, mem.tags, mem.relevance, mem.author, mem.authority, mem.confidence, mem.reinforcement_count, mem.content_hash, mem.status, mem.tier, mem.supersedes || null, mem.superseded_by || null, mem.last_reinforced || null, mem.created, mem.modified, mem.embedding || null, mem.source_path || null, mem.source_file || null, mem.source_page || null, mem.source_timerange || null, mem.project_id || null, mem.scope || "project");
828
+ stmt.run(mem.id, mem.title, mem.category, mem.content, mem.summary || null, mem.tags, mem.relevance, mem.author, mem.authority, mem.confidence, mem.reinforcement_count, mem.content_hash, mem.status, mem.tier, mem.supersedes || null, mem.superseded_by || null, mem.last_reinforced || null, mem.created, mem.modified, mem.embedding || null, mem.source_path || null, mem.source_file || null, mem.source_page || null, mem.source_timerange || null, mem.attachment_data || null, mem.attachment_mime || null, mem.attachment_name || null, mem.project_id || null, mem.scope || "project");
762
829
  });
763
830
  }
764
831
  getMemory(id) {
765
- return this.withRecovery(() => this.db.prepare("SELECT * FROM memories WHERE id = ?").get(id) || null);
832
+ return this.withRecovery(() => this.prep("SELECT * FROM memories WHERE id = ?").get(id) || null);
766
833
  }
767
834
  getActiveMemories() {
768
- return this.withRecovery(() => this.db.prepare("SELECT * FROM memories WHERE tier = 'active' AND status = 'active'").all());
835
+ // v5.12.x perf: project NULL for the two BLOB columns see
836
+ // LEAN_MEMORY_PROJECTION for the rationale and measured numbers.
837
+ return this.withRecovery(() => this.prep(`SELECT ${LEAN_MEMORY_PROJECTION} FROM memories WHERE tier = 'active' AND status = 'active'`).all());
769
838
  }
770
839
  getAllMemories() {
771
840
  return this.withRecovery(() => this.db.prepare("SELECT * FROM memories").all());
@@ -795,20 +864,20 @@ export class GnosysDB {
795
864
  this.db.pragma(`busy_timeout = ${Math.max(0, Math.floor(ms))}`);
796
865
  }
797
866
  getMemoriesByCategory(category) {
798
- return this.db.prepare("SELECT * FROM memories WHERE category = ? AND tier = 'active'").all(category);
867
+ return this.withRecovery(() => this.prep("SELECT * FROM memories WHERE category = ? AND tier = 'active'").all(category));
799
868
  }
800
869
  getRelationshipsForMemoryIds(ids) {
801
870
  if (ids.length === 0)
802
871
  return [];
803
872
  const placeholders = ids.map(() => "?").join(",");
804
- return this.db
873
+ return this.withRecovery(() => this.db
805
874
  .prepare(`SELECT * FROM relationships WHERE source_id IN (${placeholders}) OR target_id IN (${placeholders})`)
806
- .all(...ids, ...ids);
875
+ .all(...ids, ...ids));
807
876
  }
808
877
  getAuditEntriesByProject(projectId) {
809
- return this.db
878
+ return this.withRecovery(() => this.db
810
879
  .prepare("SELECT * FROM audit_log WHERE memory_id IN (SELECT id FROM memories WHERE project_id = ?) ORDER BY id")
811
- .all(projectId);
880
+ .all(projectId));
812
881
  }
813
882
  updateMemory(id, updates) {
814
883
  const fields = [];
@@ -825,17 +894,18 @@ export class GnosysDB {
825
894
  return;
826
895
  values.push(id);
827
896
  const sql = `UPDATE memories SET ${fields.join(", ")} WHERE id = ?`;
828
- try {
829
- this.db.prepare(sql).run(...values);
830
- }
831
- catch {
832
- // FTS5 update trigger may fail if INSERT OR REPLACE left FTS inconsistent.
833
- // Workaround: drop the trigger, update manually, rebuild FTS entry.
834
- this.db.exec("DROP TRIGGER IF EXISTS memories_fts_au");
835
- this.db.prepare(sql).run(...values);
836
- // Recreate trigger
897
+ return this.withRecovery(() => {
837
898
  try {
838
- this.db.exec(`
899
+ this.db.prepare(sql).run(...values);
900
+ }
901
+ catch {
902
+ // FTS5 update trigger may fail if INSERT OR REPLACE left FTS inconsistent.
903
+ // Workaround: drop the trigger, update manually, rebuild FTS entry.
904
+ this.db.exec("DROP TRIGGER IF EXISTS memories_fts_au");
905
+ this.db.prepare(sql).run(...values);
906
+ // Recreate trigger
907
+ try {
908
+ this.db.exec(`
839
909
  CREATE TRIGGER IF NOT EXISTS memories_fts_au AFTER UPDATE ON memories BEGIN
840
910
  INSERT INTO memories_fts(memories_fts, id, title, category, tags, relevance, content, summary)
841
911
  VALUES ('delete', old.id, old.title, old.category, old.tags, old.relevance, old.content, old.summary);
@@ -843,66 +913,73 @@ export class GnosysDB {
843
913
  VALUES (new.id, new.title, new.category, new.tags, new.relevance, new.content, new.summary);
844
914
  END;
845
915
  `);
916
+ }
917
+ catch {
918
+ // Trigger recreation failed — not critical
919
+ }
846
920
  }
847
- catch {
848
- // Trigger recreation failed — not critical
849
- }
850
- }
851
- // Manually sync FTS: remove old entry, insert updated entry (reliable for standalone FTS5)
852
- try {
853
- this.db.prepare("DELETE FROM memories_fts WHERE id = ?").run(id);
854
- }
855
- catch {
856
- // Old FTS entry may not exist — that's OK
857
- }
858
- const newMem = this.db.prepare("SELECT * FROM memories WHERE id = ?").get(id);
859
- if (newMem) {
921
+ // Manually sync FTS: remove old entry, insert updated entry (reliable for standalone FTS5)
860
922
  try {
861
- this.db.prepare("INSERT INTO memories_fts(id, title, category, tags, relevance, content, summary) VALUES (?, ?, ?, ?, ?, ?, ?)").run(newMem.id, newMem.title, newMem.category, newMem.tags, newMem.relevance, newMem.content, newMem.summary);
923
+ this.db.prepare("DELETE FROM memories_fts WHERE id = ?").run(id);
862
924
  }
863
925
  catch {
864
- // FTS insert may failnot critical
926
+ // Old FTS entry may not exist that's OK
865
927
  }
866
- }
928
+ const newMem = this.db.prepare("SELECT * FROM memories WHERE id = ?").get(id);
929
+ if (newMem) {
930
+ try {
931
+ this.db.prepare("INSERT INTO memories_fts(id, title, category, tags, relevance, content, summary) VALUES (?, ?, ?, ?, ?, ?, ?)").run(newMem.id, newMem.title, newMem.category, newMem.tags, newMem.relevance, newMem.content, newMem.summary);
932
+ }
933
+ catch {
934
+ // FTS insert may fail — not critical
935
+ }
936
+ }
937
+ });
867
938
  }
868
939
  deleteMemory(id) {
869
- // FTS5 delete trigger may fail if INSERT OR REPLACE left FTS inconsistent.
870
- try {
871
- this.db.prepare("DELETE FROM memories WHERE id = ?").run(id);
872
- }
873
- catch {
874
- // FTS trigger failed — drop trigger, delete without it
875
- this.db.exec("DROP TRIGGER IF EXISTS memories_fts_ad");
876
- this.db.prepare("DELETE FROM memories WHERE id = ?").run(id);
877
- // Recreate trigger
940
+ return this.withRecovery(() => {
941
+ // FTS5 delete trigger may fail if INSERT OR REPLACE left FTS inconsistent.
878
942
  try {
879
- this.db.exec(`
943
+ this.db.prepare("DELETE FROM memories WHERE id = ?").run(id);
944
+ }
945
+ catch {
946
+ // FTS trigger failed — drop trigger, delete without it
947
+ this.db.exec("DROP TRIGGER IF EXISTS memories_fts_ad");
948
+ this.db.prepare("DELETE FROM memories WHERE id = ?").run(id);
949
+ // Recreate trigger
950
+ try {
951
+ this.db.exec(`
880
952
  CREATE TRIGGER IF NOT EXISTS memories_fts_ad AFTER DELETE ON memories BEGIN
881
953
  INSERT INTO memories_fts(memories_fts, id, title, category, tags, relevance, content, summary)
882
954
  VALUES ('delete', old.id, old.title, old.category, old.tags, old.relevance, old.content, old.summary);
883
955
  END;
884
956
  `);
957
+ }
958
+ catch {
959
+ // Trigger recreation failed — not critical
960
+ }
961
+ }
962
+ // Ensure FTS entry is also removed (direct DELETE is reliable for standalone FTS5)
963
+ try {
964
+ this.db.prepare("DELETE FROM memories_fts WHERE id = ?").run(id);
885
965
  }
886
966
  catch {
887
- // Trigger recreation failednot critical
967
+ // FTS entry may not exist that's OK
888
968
  }
889
- }
890
- // Ensure FTS entry is also removed (direct DELETE is reliable for standalone FTS5)
891
- try {
892
- this.db.prepare("DELETE FROM memories_fts WHERE id = ?").run(id);
893
- }
894
- catch {
895
- // FTS entry may not exist — that's OK
896
- }
969
+ });
897
970
  }
898
971
  getMemoryCount() {
899
- const active = this.db.prepare("SELECT COUNT(*) as cnt FROM memories WHERE tier = 'active'").get().cnt;
900
- const archived = this.db.prepare("SELECT COUNT(*) as cnt FROM memories WHERE tier = 'archive'").get().cnt;
901
- return { active, archived, total: active + archived };
972
+ return this.withRecovery(() => {
973
+ const active = this.db.prepare("SELECT COUNT(*) as cnt FROM memories WHERE tier = 'active'").get().cnt;
974
+ const archived = this.db.prepare("SELECT COUNT(*) as cnt FROM memories WHERE tier = 'archive'").get().cnt;
975
+ return { active, archived, total: active + archived };
976
+ });
902
977
  }
903
978
  getCategories() {
904
- const rows = this.db.prepare("SELECT DISTINCT category FROM memories WHERE tier = 'active' ORDER BY category").all();
905
- return rows.map((r) => r.category);
979
+ return this.withRecovery(() => {
980
+ const rows = this.db.prepare("SELECT DISTINCT category FROM memories WHERE tier = 'active' ORDER BY category").all();
981
+ return rows.map((r) => r.category);
982
+ });
906
983
  }
907
984
  // ─── Scoped Queries (v3.0) ──────────────────────────────────────────
908
985
  /**
@@ -912,13 +989,13 @@ export class GnosysDB {
912
989
  const sql = includeArchived
913
990
  ? "SELECT * FROM memories WHERE project_id = ?"
914
991
  : "SELECT * FROM memories WHERE project_id = ? AND tier = 'active' AND status = 'active'";
915
- return this.db.prepare(sql).all(projectId);
992
+ return this.withRecovery(() => this.prep(sql).all(projectId));
916
993
  }
917
994
  /**
918
995
  * Get memories by scope (project, user, global).
919
996
  */
920
997
  getMemoriesByScope(scope) {
921
- return this.db.prepare("SELECT * FROM memories WHERE scope = ? AND tier = 'active' AND status = 'active'").all(scope);
998
+ return this.withRecovery(() => this.prep("SELECT * FROM memories WHERE scope = ? AND tier = 'active' AND status = 'active'").all(scope));
922
999
  }
923
1000
  // ─── Project Identity (v3.0) ──────────────────────────────────────
924
1001
  insertProject(project) {
@@ -982,10 +1059,14 @@ export class GnosysDB {
982
1059
  if (fields.length === 0)
983
1060
  return;
984
1061
  values.push(id);
985
- this.db.prepare(`UPDATE projects SET ${fields.join(", ")} WHERE id = ?`).run(...values);
1062
+ this.withRecovery(() => {
1063
+ this.db.prepare(`UPDATE projects SET ${fields.join(", ")} WHERE id = ?`).run(...values);
1064
+ });
986
1065
  }
987
1066
  deleteProject(id) {
988
- this.db.prepare("DELETE FROM projects WHERE id = ?").run(id);
1067
+ this.withRecovery(() => {
1068
+ this.db.prepare("DELETE FROM projects WHERE id = ?").run(id);
1069
+ });
989
1070
  }
990
1071
  /**
991
1072
  * Generate the next sequential ID for a category.
@@ -1002,43 +1083,50 @@ export class GnosysDB {
1002
1083
  * The `projectId` parameter is accepted for API compatibility but no longer
1003
1084
  * used for ID generation (ULIDs don't need project scoping for uniqueness).
1004
1085
  */
1005
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
1006
1086
  getNextId(category, projectId) {
1007
1087
  const prefix = category.substring(0, 4);
1008
1088
  return `${prefix}-${ulid()}`;
1009
1089
  }
1010
1090
  // ─── FTS5 Search ────────────────────────────────────────────────────
1011
1091
  searchFts(query, limit = 20) {
1012
- const safeQuery = query.replace(/['"]/g, "").trim();
1013
- if (!safeQuery)
1092
+ const terms = ftsTerms(query);
1093
+ if (terms.length === 0)
1014
1094
  return [];
1015
1095
  // v5.8.0 (#7): join memories so callers can render project-prefixed IDs.
1016
- try {
1017
- return this.db.prepare(`
1018
- SELECT m.id AS id, m.title AS title,
1019
- snippet(memories_fts, 5, '>>>', '<<<', '...', 40) as snippet,
1020
- fts.rank AS rank,
1021
- m.project_id AS project_id
1022
- FROM memories_fts fts
1023
- JOIN memories m ON m.id = fts.id
1024
- WHERE memories_fts MATCH ?
1025
- ORDER BY fts.rank
1026
- LIMIT ?
1027
- `).all(safeQuery, limit);
1028
- }
1029
- catch {
1030
- // FTS5 syntax error fallback to LIKE
1031
- const pattern = `%${safeQuery}%`;
1032
- return this.db.prepare(`
1096
+ return this.withRecovery(() => {
1097
+ try {
1098
+ const run = (match) => this.prep(`
1099
+ SELECT m.id AS id, m.title AS title,
1100
+ snippet(memories_fts, 5, '>>>', '<<<', '...', 40) as snippet,
1101
+ fts.rank AS rank,
1102
+ m.project_id AS project_id
1103
+ FROM memories_fts fts
1104
+ JOIN memories m ON m.id = fts.id
1105
+ WHERE memories_fts MATCH ?
1106
+ ORDER BY fts.rank
1107
+ LIMIT ?
1108
+ `).all(match, limit);
1109
+ // v5.12.3: AND first (precision), OR retry when AND finds nothing —
1110
+ // multi-word queries previously required every term to match.
1111
+ const results = run(ftsAndQuery(terms));
1112
+ if (results.length > 0 || terms.length === 1)
1113
+ return results;
1114
+ return run(ftsOrQuery(terms));
1115
+ }
1116
+ catch {
1117
+ // FTS5 syntax error — fallback to LIKE
1118
+ const pattern = `%${terms.join(" ")}%`;
1119
+ return this.prep(`
1033
1120
  SELECT id, title, substr(content, 1, 200) as snippet, 0 as rank, project_id
1034
1121
  FROM memories WHERE content LIKE ? OR title LIKE ? OR tags LIKE ?
1035
1122
  LIMIT ?
1036
1123
  `).all(pattern, pattern, pattern, limit);
1037
- }
1124
+ }
1125
+ });
1038
1126
  }
1039
1127
  discoverFts(query, limit = 20) {
1040
- const safeQuery = query.replace(/['"]/g, "").trim();
1041
- if (!safeQuery)
1128
+ const terms = ftsTerms(query);
1129
+ if (terms.length === 0)
1042
1130
  return [];
1043
1131
  // v5.7.1 (#14): join `memories` so callers can render project-prefixed IDs.
1044
1132
  const select = `
@@ -1049,51 +1137,72 @@ export class GnosysDB {
1049
1137
  ORDER BY fts.rank
1050
1138
  LIMIT ?
1051
1139
  `;
1052
- try {
1053
- const colQuery = `{relevance title tags} : ${safeQuery}`;
1054
- const results = this.db.prepare(select).all(colQuery, limit);
1055
- if (results.length > 0)
1056
- return results;
1057
- return this.db.prepare(select).all(safeQuery, limit);
1058
- }
1059
- catch {
1140
+ // Let corruption escape to withRecovery (reopen + retry); plain FTS
1141
+ // syntax failures still degrade gracefully to "no results".
1142
+ const tryRun = (match) => {
1060
1143
  try {
1061
- return this.db.prepare(select).all(safeQuery, limit);
1144
+ return this.prep(select).all(match, limit);
1062
1145
  }
1063
- catch {
1146
+ catch (err) {
1147
+ if (GnosysDB.isCorruptionError(err))
1148
+ throw err;
1064
1149
  return [];
1065
1150
  }
1066
- }
1151
+ };
1152
+ // v5.12.3: precision-to-recall ladder. AND on the metadata columns,
1153
+ // AND anywhere, then OR retries — multi-word queries previously
1154
+ // required every term to match, so long queries returned nothing.
1155
+ // Parens scope the column filter to the whole expression (a bare
1156
+ // `{cols} : a b` only filtered the first term).
1157
+ const colScoped = (expr) => `{relevance title tags} : (${expr})`;
1158
+ const andExpr = ftsAndQuery(terms);
1159
+ return this.withRecovery(() => {
1160
+ let results = tryRun(colScoped(andExpr));
1161
+ if (results.length > 0)
1162
+ return results;
1163
+ results = tryRun(andExpr);
1164
+ if (results.length > 0 || terms.length === 1)
1165
+ return results;
1166
+ const orExpr = ftsOrQuery(terms);
1167
+ results = tryRun(colScoped(orExpr));
1168
+ if (results.length > 0)
1169
+ return results;
1170
+ return tryRun(orExpr);
1171
+ });
1067
1172
  }
1068
1173
  // ─── Relationships ──────────────────────────────────────────────────
1069
1174
  insertRelationship(rel) {
1070
- this.db.prepare(`
1071
- INSERT OR IGNORE INTO relationships (source_id, target_id, rel_type, label, confidence, created)
1072
- VALUES (?, ?, ?, ?, ?, ?)
1073
- `).run(rel.source_id, rel.target_id, rel.rel_type, rel.label, rel.confidence, rel.created);
1175
+ this.withRecovery(() => {
1176
+ this.db.prepare(`
1177
+ INSERT OR IGNORE INTO relationships (source_id, target_id, rel_type, label, confidence, created)
1178
+ VALUES (?, ?, ?, ?, ?, ?)
1179
+ `).run(rel.source_id, rel.target_id, rel.rel_type, rel.label, rel.confidence, rel.created);
1180
+ });
1074
1181
  }
1075
1182
  getRelationshipsFrom(id) {
1076
- return this.db.prepare("SELECT * FROM relationships WHERE source_id = ?").all(id);
1183
+ return this.withRecovery(() => this.db.prepare("SELECT * FROM relationships WHERE source_id = ?").all(id));
1077
1184
  }
1078
1185
  getRelationshipsTo(id) {
1079
- return this.db.prepare("SELECT * FROM relationships WHERE target_id = ?").all(id);
1186
+ return this.withRecovery(() => this.db.prepare("SELECT * FROM relationships WHERE target_id = ?").all(id));
1080
1187
  }
1081
1188
  // ─── Summaries ──────────────────────────────────────────────────────
1082
1189
  upsertSummary(summary) {
1083
- this.db.prepare(`
1084
- INSERT INTO summaries (id, scope, scope_key, content, source_ids, created, modified)
1085
- VALUES (?, ?, ?, ?, ?, ?, ?)
1086
- ON CONFLICT(scope, scope_key) DO UPDATE SET
1087
- content = excluded.content,
1088
- source_ids = excluded.source_ids,
1089
- modified = excluded.modified
1090
- `).run(summary.id, summary.scope, summary.scope_key, summary.content, summary.source_ids, summary.created, summary.modified);
1190
+ this.withRecovery(() => {
1191
+ this.db.prepare(`
1192
+ INSERT INTO summaries (id, scope, scope_key, content, source_ids, created, modified)
1193
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1194
+ ON CONFLICT(scope, scope_key) DO UPDATE SET
1195
+ content = excluded.content,
1196
+ source_ids = excluded.source_ids,
1197
+ modified = excluded.modified
1198
+ `).run(summary.id, summary.scope, summary.scope_key, summary.content, summary.source_ids, summary.created, summary.modified);
1199
+ });
1091
1200
  }
1092
1201
  getSummary(scope, scopeKey) {
1093
- return this.db.prepare("SELECT * FROM summaries WHERE scope = ? AND scope_key = ?").get(scope, scopeKey) || null;
1202
+ return this.withRecovery(() => this.db.prepare("SELECT * FROM summaries WHERE scope = ? AND scope_key = ?").get(scope, scopeKey) || null);
1094
1203
  }
1095
1204
  getAllSummaries() {
1096
- return this.db.prepare("SELECT * FROM summaries").all();
1205
+ return this.withRecovery(() => this.db.prepare("SELECT * FROM summaries").all());
1097
1206
  }
1098
1207
  // ─── Audit ──────────────────────────────────────────────────────────
1099
1208
  logAudit(entry) {
@@ -1145,21 +1254,26 @@ export class GnosysDB {
1145
1254
  }
1146
1255
  // ─── Embeddings ─────────────────────────────────────────────────────
1147
1256
  updateEmbedding(id, embedding) {
1148
- this.db.prepare("UPDATE memories SET embedding = ? WHERE id = ?").run(embedding, id);
1257
+ this.withRecovery(() => {
1258
+ this.db.prepare("UPDATE memories SET embedding = ? WHERE id = ?").run(embedding, id);
1259
+ });
1149
1260
  }
1150
1261
  getEmbedding(id) {
1151
- const row = this.db.prepare("SELECT embedding FROM memories WHERE id = ?").get(id);
1152
- return row?.embedding || null;
1262
+ return this.withRecovery(() => {
1263
+ const row = this.db.prepare("SELECT embedding FROM memories WHERE id = ?").get(id);
1264
+ return row?.embedding || null;
1265
+ });
1153
1266
  }
1154
1267
  getAllEmbeddings() {
1155
- return this.db.prepare("SELECT id, embedding FROM memories WHERE embedding IS NOT NULL").all();
1268
+ return this.withRecovery(() => this.db.prepare("SELECT id, embedding FROM memories WHERE embedding IS NOT NULL").all());
1156
1269
  }
1157
1270
  getEmbeddingCount() {
1158
- const row = this.db.prepare("SELECT COUNT(*) as cnt FROM memories WHERE embedding IS NOT NULL").get();
1159
- return row.cnt;
1271
+ return this.withRecovery(() => {
1272
+ const row = this.db.prepare("SELECT COUNT(*) as cnt FROM memories WHERE embedding IS NOT NULL").get();
1273
+ return row.cnt;
1274
+ });
1160
1275
  }
1161
1276
  // ─── Transactions ───────────────────────────────────────────────────
1162
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1163
1277
  transaction(fn) {
1164
1278
  return this.db.transaction(fn)();
1165
1279
  }
@@ -1403,6 +1517,7 @@ export class GnosysDB {
1403
1517
  }
1404
1518
  // ─── Lifecycle ──────────────────────────────────────────────────────
1405
1519
  close() {
1520
+ this.stmtCache.clear();
1406
1521
  this.db?.close();
1407
1522
  }
1408
1523
  // ─── Migration Status ───────────────────────────────────────────────
@@ -14,7 +14,7 @@
14
14
  * become optional — controlled by config.
15
15
  */
16
16
  import type { GnosysDB } from "./db.js";
17
- import { type MemoryFrontmatter } from "./store.js";
17
+ import type { MemoryFrontmatter } from "./store.js";
18
18
  /**
19
19
  * Sync a memory write to gnosys.db after it's been written to .md.
20
20
  * Call this after GnosysStore.writeMemory() or updateMemory().
@@ -18,7 +18,7 @@ export async function runDearchiveCommand(getResolver, query, opts) {
18
18
  console.error("Archive not available. Install it with: npm install better-sqlite3");
19
19
  process.exit(1);
20
20
  }
21
- const results = archive.searchArchive(query, parseInt(opts.limit));
21
+ const results = archive.searchArchive(query, parseInt(opts.limit, 10));
22
22
  if (results.length === 0) {
23
23
  console.log(`No archived memories found matching "${query}".`);
24
24
  return;