gnosys 5.12.0 → 5.12.2

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 (72) hide show
  1. package/dist/cli.js +48 -7
  2. package/dist/index.js +179 -10
  3. package/dist/lib/addCommand.js +0 -1
  4. package/dist/lib/archive.js +0 -2
  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 +216 -119
  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/hybridSearchCommand.js +1 -1
  29. package/dist/lib/importProject.js +2 -1
  30. package/dist/lib/llm.js +1 -1
  31. package/dist/lib/lock.d.ts +1 -1
  32. package/dist/lib/lock.js +5 -3
  33. package/dist/lib/migrate.js +0 -1
  34. package/dist/lib/multimodalIngest.js +1 -1
  35. package/dist/lib/platform.d.ts +0 -6
  36. package/dist/lib/platform.js +0 -28
  37. package/dist/lib/readCommand.js +11 -10
  38. package/dist/lib/remoteWizard.d.ts +1 -1
  39. package/dist/lib/remoteWizard.js +4 -4
  40. package/dist/lib/rulesGen.d.ts +8 -0
  41. package/dist/lib/rulesGen.js +16 -0
  42. package/dist/lib/search.d.ts +0 -2
  43. package/dist/lib/search.js +0 -7
  44. package/dist/lib/semanticSearchCommand.js +1 -1
  45. package/dist/lib/setup/sections/providers.js +56 -4
  46. package/dist/lib/setup/sections/routing.js +42 -5
  47. package/dist/lib/setup/sections/taskRoutingEditor.d.ts +1 -5
  48. package/dist/lib/setup/sections/taskRoutingEditor.js +0 -10
  49. package/dist/lib/setup/ui/header.js +0 -1
  50. package/dist/lib/setup/ui/status.d.ts +0 -1
  51. package/dist/lib/setup/ui/status.js +0 -2
  52. package/dist/lib/setup.d.ts +0 -15
  53. package/dist/lib/setup.js +13 -158
  54. package/dist/lib/staleCommand.js +2 -2
  55. package/dist/lib/syncClient.d.ts +0 -6
  56. package/dist/lib/syncClient.js +36 -14
  57. package/dist/lib/syncDoctorCommand.js +2 -2
  58. package/dist/lib/syncIngest.d.ts +11 -0
  59. package/dist/lib/syncIngest.js +24 -1
  60. package/dist/lib/syncIngestStartup.js +2 -2
  61. package/dist/lib/syncSnapshot.d.ts +2 -0
  62. package/dist/lib/syncSnapshot.js +4 -0
  63. package/dist/lib/syncStaging.d.ts +0 -2
  64. package/dist/lib/syncStaging.js +0 -2
  65. package/dist/lib/updateCommand.js +1 -1
  66. package/dist/lib/webBuildCommand.js +1 -1
  67. package/dist/lib/webIndex.js +0 -1
  68. package/dist/lib/webIngestCommand.js +1 -1
  69. package/dist/sandbox/client.js +1 -1
  70. package/dist/sandbox/manager.js +1 -14
  71. package/dist/sandbox/server.js +3 -5
  72. 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;
@@ -51,6 +50,9 @@ CREATE TABLE IF NOT EXISTS memories (
51
50
  source_file TEXT,
52
51
  source_page TEXT,
53
52
  source_timerange TEXT,
53
+ attachment_data BLOB,
54
+ attachment_mime TEXT,
55
+ attachment_name TEXT,
54
56
  project_id TEXT,
55
57
  scope TEXT DEFAULT 'project' CHECK(scope IN ('project','user','global'))
56
58
  );
@@ -243,12 +245,27 @@ const MEMORY_COLUMNS = new Set([
243
245
  "status", "tier", "supersedes", "superseded_by", "last_reinforced",
244
246
  "created", "modified", "embedding", "source_path",
245
247
  "source_file", "source_page", "source_timerange",
248
+ "attachment_data", "attachment_mime", "attachment_name",
246
249
  "project_id", "scope",
247
250
  ]);
248
251
  const PROJECT_COLUMNS = new Set([
249
252
  "name", "working_directory", "root_id", "rel_path", "user",
250
253
  "agent_rules_target", "obsidian_vault", "created", "modified",
251
254
  ]);
255
+ /**
256
+ * v5.12.x perf: full DbMemory shape with the two BLOB columns projected as
257
+ * NULL. List-style reads (recall, federation, list) never consume embedding
258
+ * or attachment bytes. Measured win on embedding-only rows is modest (~4%
259
+ * plus avoided Buffer churn), but attachments are the real reason: a single
260
+ * ~10MB gnosys_attach blob would otherwise be hydrated on EVERY list call.
261
+ * Blob consumers use getAllEmbeddings/getEmbedding/getMemoryAttachment, and
262
+ * getAllMemories/getMemoriesByProject still return full rows (remote sync
263
+ * and project export push them verbatim).
264
+ */
265
+ const LEAN_MEMORY_PROJECTION = [
266
+ "id", ...[...MEMORY_COLUMNS].filter((c) => c !== "embedding" && c !== "attachment_data"),
267
+ "NULL AS embedding", "NULL AS attachment_data",
268
+ ].join(", ");
252
269
  // ─── FNV-1a hash (same as embeddings.ts) ────────────────────────────────
253
270
  function fnv1a(str) {
254
271
  let hash = 0x811c9dc5;
@@ -260,8 +277,10 @@ function fnv1a(str) {
260
277
  }
261
278
  // ─── GnosysDB Class ─────────────────────────────────────────────────────
262
279
  export class GnosysDB {
263
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
264
280
  db = null;
281
+ /** v5.12.x perf: prepared-statement cache, keyed by SQL. Invalidated on
282
+ * reopen()/close() — statements are bound to their connection handle. */
283
+ stmtCache = new Map();
265
284
  storePath;
266
285
  available = false;
267
286
  dbFilePath;
@@ -381,7 +400,8 @@ export class GnosysDB {
381
400
  try {
382
401
  fs.mkdirSync(storePath, { recursive: true, mode: 0o700 });
383
402
  this.db = new Database(this.dbFilePath);
384
- enableWAL(this.db);
403
+ // Longer busy timeout for network shares (10s)
404
+ enableWAL(this.db, 10000);
385
405
  try {
386
406
  fs.chmodSync(storePath, 0o700);
387
407
  fs.chmodSync(this.dbFilePath, 0o600);
@@ -396,13 +416,11 @@ export class GnosysDB {
396
416
  // best-effort (Windows / network FS)
397
417
  }
398
418
  this.db.pragma("foreign_keys = ON");
399
- // Longer busy timeout for network shares (10s)
400
- this.db.pragma("busy_timeout = 10000");
401
419
  this.applySchema();
402
420
  this.available = true;
403
421
  return; // Success
404
422
  }
405
- catch (err) {
423
+ catch (_err) {
406
424
  this.db = null;
407
425
  if (attempt < maxRetries) {
408
426
  // Synchronous delay for constructor (network share retry)
@@ -471,6 +489,7 @@ export class GnosysDB {
471
489
  * file handles after a WAL checkpoint or remount.
472
490
  */
473
491
  reopen() {
492
+ this.stmtCache.clear();
474
493
  try {
475
494
  this.db?.close();
476
495
  }
@@ -483,9 +502,19 @@ export class GnosysDB {
483
502
  return;
484
503
  try {
485
504
  this.db = new Database(this.dbFilePath);
486
- enableWAL(this.db);
505
+ // Longer busy timeout for network shares (10s)
506
+ enableWAL(this.db, 10000);
487
507
  this.db.pragma("foreign_keys = ON");
488
- this.db.pragma("busy_timeout = 10000");
508
+ // v5.12.1: heal FTS triggers on recovery. updateMemory/deleteMemory may
509
+ // drop a trigger in their inconsistency fallback; recreating here
510
+ // (idempotent CREATE TRIGGER IF NOT EXISTS) means recovery restores
511
+ // them instead of waiting for the next process start.
512
+ try {
513
+ this.db.exec(FTS_TRIGGERS_SQL);
514
+ }
515
+ catch {
516
+ // non-fatal — applySchema() heals at next open
517
+ }
489
518
  this.available = true;
490
519
  }
491
520
  catch {
@@ -508,15 +537,28 @@ export class GnosysDB {
508
537
  * Read methods are also wrapped because reads against stale pages can
509
538
  * surface the same error.
510
539
  */
540
+ /**
541
+ * Prepare-with-cache for fixed-SQL hot paths. Dynamic SQL (e.g. the
542
+ * field-built UPDATE in updateMemory) must keep using db.prepare directly
543
+ * so the cache stays bounded. Cache is invalidated on reopen()/close().
544
+ */
545
+ prep(sql) {
546
+ let stmt = this.stmtCache.get(sql);
547
+ if (!stmt) {
548
+ stmt = this.db.prepare(sql);
549
+ this.stmtCache.set(sql, stmt);
550
+ }
551
+ return stmt;
552
+ }
511
553
  withRecovery(fn) {
512
554
  try {
513
555
  return fn();
514
556
  }
515
557
  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)
558
+ // v5.12.1: use the shared corruption detector — it also matches
559
+ // SQLITE_NOTADB ("file is not a database"), which surfaces on network
560
+ // shares when a sync layer swaps the file under a live handle.
561
+ if (!GnosysDB.isCorruptionError(err))
520
562
  throw err;
521
563
  // One-shot recovery: reopen and retry. If the reopen itself fails or
522
564
  // the retry surfaces the same error, that's a real corruption case —
@@ -687,6 +729,29 @@ export class GnosysDB {
687
729
  }
688
730
  }
689
731
  if (fromVersion < 5) {
732
+ // v4 → v5 (v5.12): inline binary attachments carried in the memory row.
733
+ // Additive columns only — existing rows get NULLs. These ride the same
734
+ // row-copy sync path as `embedding`, so attachments travel machine to
735
+ // machine for free.
736
+ try {
737
+ this.db.exec("ALTER TABLE memories ADD COLUMN attachment_data BLOB");
738
+ }
739
+ catch {
740
+ // Column already exists — fine
741
+ }
742
+ try {
743
+ this.db.exec("ALTER TABLE memories ADD COLUMN attachment_mime TEXT");
744
+ }
745
+ catch {
746
+ // Column already exists — fine
747
+ }
748
+ try {
749
+ this.db.exec("ALTER TABLE memories ADD COLUMN attachment_name TEXT");
750
+ }
751
+ catch {
752
+ // Column already exists — fine
753
+ }
754
+ // Sync staging / multi-machine ledger tables (from network-mcp work on feat).
690
755
  try {
691
756
  this.db.exec(`
692
757
  CREATE TABLE IF NOT EXISTS sync_staging_ledger (
@@ -749,23 +814,26 @@ export class GnosysDB {
749
814
  // ─── Memory CRUD ────────────────────────────────────────────────────
750
815
  insertMemory(mem) {
751
816
  return this.withRecovery(() => {
752
- const stmt = this.db.prepare(`
817
+ const stmt = this.prep(`
753
818
  INSERT OR REPLACE INTO memories
754
819
  (id, title, category, content, summary, tags, relevance, author, authority,
755
820
  confidence, reinforcement_count, content_hash, status, tier, supersedes,
756
821
  superseded_by, last_reinforced, created, modified, embedding, source_path,
757
822
  source_file, source_page, source_timerange,
823
+ attachment_data, attachment_mime, attachment_name,
758
824
  project_id, scope)
759
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
825
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
760
826
  `);
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");
827
+ 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
828
  });
763
829
  }
764
830
  getMemory(id) {
765
- return this.withRecovery(() => this.db.prepare("SELECT * FROM memories WHERE id = ?").get(id) || null);
831
+ return this.withRecovery(() => this.prep("SELECT * FROM memories WHERE id = ?").get(id) || null);
766
832
  }
767
833
  getActiveMemories() {
768
- return this.withRecovery(() => this.db.prepare("SELECT * FROM memories WHERE tier = 'active' AND status = 'active'").all());
834
+ // v5.12.x perf: project NULL for the two BLOB columns see
835
+ // LEAN_MEMORY_PROJECTION for the rationale and measured numbers.
836
+ return this.withRecovery(() => this.prep(`SELECT ${LEAN_MEMORY_PROJECTION} FROM memories WHERE tier = 'active' AND status = 'active'`).all());
769
837
  }
770
838
  getAllMemories() {
771
839
  return this.withRecovery(() => this.db.prepare("SELECT * FROM memories").all());
@@ -795,20 +863,20 @@ export class GnosysDB {
795
863
  this.db.pragma(`busy_timeout = ${Math.max(0, Math.floor(ms))}`);
796
864
  }
797
865
  getMemoriesByCategory(category) {
798
- return this.db.prepare("SELECT * FROM memories WHERE category = ? AND tier = 'active'").all(category);
866
+ return this.withRecovery(() => this.prep("SELECT * FROM memories WHERE category = ? AND tier = 'active'").all(category));
799
867
  }
800
868
  getRelationshipsForMemoryIds(ids) {
801
869
  if (ids.length === 0)
802
870
  return [];
803
871
  const placeholders = ids.map(() => "?").join(",");
804
- return this.db
872
+ return this.withRecovery(() => this.db
805
873
  .prepare(`SELECT * FROM relationships WHERE source_id IN (${placeholders}) OR target_id IN (${placeholders})`)
806
- .all(...ids, ...ids);
874
+ .all(...ids, ...ids));
807
875
  }
808
876
  getAuditEntriesByProject(projectId) {
809
- return this.db
877
+ return this.withRecovery(() => this.db
810
878
  .prepare("SELECT * FROM audit_log WHERE memory_id IN (SELECT id FROM memories WHERE project_id = ?) ORDER BY id")
811
- .all(projectId);
879
+ .all(projectId));
812
880
  }
813
881
  updateMemory(id, updates) {
814
882
  const fields = [];
@@ -825,17 +893,18 @@ export class GnosysDB {
825
893
  return;
826
894
  values.push(id);
827
895
  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
896
+ return this.withRecovery(() => {
837
897
  try {
838
- this.db.exec(`
898
+ this.db.prepare(sql).run(...values);
899
+ }
900
+ catch {
901
+ // FTS5 update trigger may fail if INSERT OR REPLACE left FTS inconsistent.
902
+ // Workaround: drop the trigger, update manually, rebuild FTS entry.
903
+ this.db.exec("DROP TRIGGER IF EXISTS memories_fts_au");
904
+ this.db.prepare(sql).run(...values);
905
+ // Recreate trigger
906
+ try {
907
+ this.db.exec(`
839
908
  CREATE TRIGGER IF NOT EXISTS memories_fts_au AFTER UPDATE ON memories BEGIN
840
909
  INSERT INTO memories_fts(memories_fts, id, title, category, tags, relevance, content, summary)
841
910
  VALUES ('delete', old.id, old.title, old.category, old.tags, old.relevance, old.content, old.summary);
@@ -843,66 +912,73 @@ export class GnosysDB {
843
912
  VALUES (new.id, new.title, new.category, new.tags, new.relevance, new.content, new.summary);
844
913
  END;
845
914
  `);
915
+ }
916
+ catch {
917
+ // Trigger recreation failed — not critical
918
+ }
846
919
  }
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) {
920
+ // Manually sync FTS: remove old entry, insert updated entry (reliable for standalone FTS5)
860
921
  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);
922
+ this.db.prepare("DELETE FROM memories_fts WHERE id = ?").run(id);
862
923
  }
863
924
  catch {
864
- // FTS insert may failnot critical
925
+ // Old FTS entry may not exist that's OK
865
926
  }
866
- }
927
+ const newMem = this.db.prepare("SELECT * FROM memories WHERE id = ?").get(id);
928
+ if (newMem) {
929
+ try {
930
+ 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);
931
+ }
932
+ catch {
933
+ // FTS insert may fail — not critical
934
+ }
935
+ }
936
+ });
867
937
  }
868
938
  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
939
+ return this.withRecovery(() => {
940
+ // FTS5 delete trigger may fail if INSERT OR REPLACE left FTS inconsistent.
878
941
  try {
879
- this.db.exec(`
942
+ this.db.prepare("DELETE FROM memories WHERE id = ?").run(id);
943
+ }
944
+ catch {
945
+ // FTS trigger failed — drop trigger, delete without it
946
+ this.db.exec("DROP TRIGGER IF EXISTS memories_fts_ad");
947
+ this.db.prepare("DELETE FROM memories WHERE id = ?").run(id);
948
+ // Recreate trigger
949
+ try {
950
+ this.db.exec(`
880
951
  CREATE TRIGGER IF NOT EXISTS memories_fts_ad AFTER DELETE ON memories BEGIN
881
952
  INSERT INTO memories_fts(memories_fts, id, title, category, tags, relevance, content, summary)
882
953
  VALUES ('delete', old.id, old.title, old.category, old.tags, old.relevance, old.content, old.summary);
883
954
  END;
884
955
  `);
956
+ }
957
+ catch {
958
+ // Trigger recreation failed — not critical
959
+ }
960
+ }
961
+ // Ensure FTS entry is also removed (direct DELETE is reliable for standalone FTS5)
962
+ try {
963
+ this.db.prepare("DELETE FROM memories_fts WHERE id = ?").run(id);
885
964
  }
886
965
  catch {
887
- // Trigger recreation failednot critical
966
+ // FTS entry may not exist that's OK
888
967
  }
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
- }
968
+ });
897
969
  }
898
970
  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 };
971
+ return this.withRecovery(() => {
972
+ const active = this.db.prepare("SELECT COUNT(*) as cnt FROM memories WHERE tier = 'active'").get().cnt;
973
+ const archived = this.db.prepare("SELECT COUNT(*) as cnt FROM memories WHERE tier = 'archive'").get().cnt;
974
+ return { active, archived, total: active + archived };
975
+ });
902
976
  }
903
977
  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);
978
+ return this.withRecovery(() => {
979
+ const rows = this.db.prepare("SELECT DISTINCT category FROM memories WHERE tier = 'active' ORDER BY category").all();
980
+ return rows.map((r) => r.category);
981
+ });
906
982
  }
907
983
  // ─── Scoped Queries (v3.0) ──────────────────────────────────────────
908
984
  /**
@@ -912,13 +988,13 @@ export class GnosysDB {
912
988
  const sql = includeArchived
913
989
  ? "SELECT * FROM memories WHERE project_id = ?"
914
990
  : "SELECT * FROM memories WHERE project_id = ? AND tier = 'active' AND status = 'active'";
915
- return this.db.prepare(sql).all(projectId);
991
+ return this.withRecovery(() => this.prep(sql).all(projectId));
916
992
  }
917
993
  /**
918
994
  * Get memories by scope (project, user, global).
919
995
  */
920
996
  getMemoriesByScope(scope) {
921
- return this.db.prepare("SELECT * FROM memories WHERE scope = ? AND tier = 'active' AND status = 'active'").all(scope);
997
+ return this.withRecovery(() => this.prep("SELECT * FROM memories WHERE scope = ? AND tier = 'active' AND status = 'active'").all(scope));
922
998
  }
923
999
  // ─── Project Identity (v3.0) ──────────────────────────────────────
924
1000
  insertProject(project) {
@@ -982,10 +1058,14 @@ export class GnosysDB {
982
1058
  if (fields.length === 0)
983
1059
  return;
984
1060
  values.push(id);
985
- this.db.prepare(`UPDATE projects SET ${fields.join(", ")} WHERE id = ?`).run(...values);
1061
+ this.withRecovery(() => {
1062
+ this.db.prepare(`UPDATE projects SET ${fields.join(", ")} WHERE id = ?`).run(...values);
1063
+ });
986
1064
  }
987
1065
  deleteProject(id) {
988
- this.db.prepare("DELETE FROM projects WHERE id = ?").run(id);
1066
+ this.withRecovery(() => {
1067
+ this.db.prepare("DELETE FROM projects WHERE id = ?").run(id);
1068
+ });
989
1069
  }
990
1070
  /**
991
1071
  * Generate the next sequential ID for a category.
@@ -1002,7 +1082,6 @@ export class GnosysDB {
1002
1082
  * The `projectId` parameter is accepted for API compatibility but no longer
1003
1083
  * used for ID generation (ULIDs don't need project scoping for uniqueness).
1004
1084
  */
1005
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
1006
1085
  getNextId(category, projectId) {
1007
1086
  const prefix = category.substring(0, 4);
1008
1087
  return `${prefix}-${ulid()}`;
@@ -1013,8 +1092,9 @@ export class GnosysDB {
1013
1092
  if (!safeQuery)
1014
1093
  return [];
1015
1094
  // v5.8.0 (#7): join memories so callers can render project-prefixed IDs.
1016
- try {
1017
- return this.db.prepare(`
1095
+ return this.withRecovery(() => {
1096
+ try {
1097
+ return this.prep(`
1018
1098
  SELECT m.id AS id, m.title AS title,
1019
1099
  snippet(memories_fts, 5, '>>>', '<<<', '...', 40) as snippet,
1020
1100
  fts.rank AS rank,
@@ -1025,16 +1105,17 @@ export class GnosysDB {
1025
1105
  ORDER BY fts.rank
1026
1106
  LIMIT ?
1027
1107
  `).all(safeQuery, limit);
1028
- }
1029
- catch {
1030
- // FTS5 syntax error — fallback to LIKE
1031
- const pattern = `%${safeQuery}%`;
1032
- return this.db.prepare(`
1108
+ }
1109
+ catch {
1110
+ // FTS5 syntax error — fallback to LIKE
1111
+ const pattern = `%${safeQuery}%`;
1112
+ return this.prep(`
1033
1113
  SELECT id, title, substr(content, 1, 200) as snippet, 0 as rank, project_id
1034
1114
  FROM memories WHERE content LIKE ? OR title LIKE ? OR tags LIKE ?
1035
1115
  LIMIT ?
1036
1116
  `).all(pattern, pattern, pattern, limit);
1037
- }
1117
+ }
1118
+ });
1038
1119
  }
1039
1120
  discoverFts(query, limit = 20) {
1040
1121
  const safeQuery = query.replace(/['"]/g, "").trim();
@@ -1049,51 +1130,61 @@ export class GnosysDB {
1049
1130
  ORDER BY fts.rank
1050
1131
  LIMIT ?
1051
1132
  `;
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 {
1133
+ return this.withRecovery(() => {
1060
1134
  try {
1061
- return this.db.prepare(select).all(safeQuery, limit);
1135
+ const colQuery = `{relevance title tags} : ${safeQuery}`;
1136
+ const results = this.prep(select).all(colQuery, limit);
1137
+ if (results.length > 0)
1138
+ return results;
1139
+ return this.prep(select).all(safeQuery, limit);
1062
1140
  }
1063
1141
  catch {
1064
- return [];
1142
+ try {
1143
+ return this.prep(select).all(safeQuery, limit);
1144
+ }
1145
+ catch (err) {
1146
+ // Let corruption escape to withRecovery (reopen + retry); plain FTS
1147
+ // syntax failures still degrade gracefully to "no results".
1148
+ if (GnosysDB.isCorruptionError(err))
1149
+ throw err;
1150
+ return [];
1151
+ }
1065
1152
  }
1066
- }
1153
+ });
1067
1154
  }
1068
1155
  // ─── Relationships ──────────────────────────────────────────────────
1069
1156
  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);
1157
+ this.withRecovery(() => {
1158
+ this.db.prepare(`
1159
+ INSERT OR IGNORE INTO relationships (source_id, target_id, rel_type, label, confidence, created)
1160
+ VALUES (?, ?, ?, ?, ?, ?)
1161
+ `).run(rel.source_id, rel.target_id, rel.rel_type, rel.label, rel.confidence, rel.created);
1162
+ });
1074
1163
  }
1075
1164
  getRelationshipsFrom(id) {
1076
- return this.db.prepare("SELECT * FROM relationships WHERE source_id = ?").all(id);
1165
+ return this.withRecovery(() => this.db.prepare("SELECT * FROM relationships WHERE source_id = ?").all(id));
1077
1166
  }
1078
1167
  getRelationshipsTo(id) {
1079
- return this.db.prepare("SELECT * FROM relationships WHERE target_id = ?").all(id);
1168
+ return this.withRecovery(() => this.db.prepare("SELECT * FROM relationships WHERE target_id = ?").all(id));
1080
1169
  }
1081
1170
  // ─── Summaries ──────────────────────────────────────────────────────
1082
1171
  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);
1172
+ this.withRecovery(() => {
1173
+ this.db.prepare(`
1174
+ INSERT INTO summaries (id, scope, scope_key, content, source_ids, created, modified)
1175
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1176
+ ON CONFLICT(scope, scope_key) DO UPDATE SET
1177
+ content = excluded.content,
1178
+ source_ids = excluded.source_ids,
1179
+ modified = excluded.modified
1180
+ `).run(summary.id, summary.scope, summary.scope_key, summary.content, summary.source_ids, summary.created, summary.modified);
1181
+ });
1091
1182
  }
1092
1183
  getSummary(scope, scopeKey) {
1093
- return this.db.prepare("SELECT * FROM summaries WHERE scope = ? AND scope_key = ?").get(scope, scopeKey) || null;
1184
+ return this.withRecovery(() => this.db.prepare("SELECT * FROM summaries WHERE scope = ? AND scope_key = ?").get(scope, scopeKey) || null);
1094
1185
  }
1095
1186
  getAllSummaries() {
1096
- return this.db.prepare("SELECT * FROM summaries").all();
1187
+ return this.withRecovery(() => this.db.prepare("SELECT * FROM summaries").all());
1097
1188
  }
1098
1189
  // ─── Audit ──────────────────────────────────────────────────────────
1099
1190
  logAudit(entry) {
@@ -1145,21 +1236,26 @@ export class GnosysDB {
1145
1236
  }
1146
1237
  // ─── Embeddings ─────────────────────────────────────────────────────
1147
1238
  updateEmbedding(id, embedding) {
1148
- this.db.prepare("UPDATE memories SET embedding = ? WHERE id = ?").run(embedding, id);
1239
+ this.withRecovery(() => {
1240
+ this.db.prepare("UPDATE memories SET embedding = ? WHERE id = ?").run(embedding, id);
1241
+ });
1149
1242
  }
1150
1243
  getEmbedding(id) {
1151
- const row = this.db.prepare("SELECT embedding FROM memories WHERE id = ?").get(id);
1152
- return row?.embedding || null;
1244
+ return this.withRecovery(() => {
1245
+ const row = this.db.prepare("SELECT embedding FROM memories WHERE id = ?").get(id);
1246
+ return row?.embedding || null;
1247
+ });
1153
1248
  }
1154
1249
  getAllEmbeddings() {
1155
- return this.db.prepare("SELECT id, embedding FROM memories WHERE embedding IS NOT NULL").all();
1250
+ return this.withRecovery(() => this.db.prepare("SELECT id, embedding FROM memories WHERE embedding IS NOT NULL").all());
1156
1251
  }
1157
1252
  getEmbeddingCount() {
1158
- const row = this.db.prepare("SELECT COUNT(*) as cnt FROM memories WHERE embedding IS NOT NULL").get();
1159
- return row.cnt;
1253
+ return this.withRecovery(() => {
1254
+ const row = this.db.prepare("SELECT COUNT(*) as cnt FROM memories WHERE embedding IS NOT NULL").get();
1255
+ return row.cnt;
1256
+ });
1160
1257
  }
1161
1258
  // ─── Transactions ───────────────────────────────────────────────────
1162
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1163
1259
  transaction(fn) {
1164
1260
  return this.db.transaction(fn)();
1165
1261
  }
@@ -1403,6 +1499,7 @@ export class GnosysDB {
1403
1499
  }
1404
1500
  // ─── Lifecycle ──────────────────────────────────────────────────────
1405
1501
  close() {
1502
+ this.stmtCache.clear();
1406
1503
  this.db?.close();
1407
1504
  }
1408
1505
  // ─── 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;
@@ -30,7 +30,7 @@ export async function extractDocxText(filePath) {
30
30
  await assertDocxDecompressedSizeWithinLimit(buffer);
31
31
  const result = await mammoth.convertToHtml({ buffer });
32
32
  const html = result.value;
33
- if (!html || !html.trim()) {
33
+ if (!html?.trim()) {
34
34
  return [];
35
35
  }
36
36
  // Convert HTML to markdown
@@ -107,6 +107,14 @@ export declare class GnosysDreamEngine {
107
107
  private providerInitError;
108
108
  private createPhase;
109
109
  private finishPhase;
110
+ /**
111
+ * v5.12.1 crash safety: persist analyzed fingerprints at every phase
112
+ * boundary, not only in finalize(). A crash mid-run previously lost all
113
+ * pendingFingerprints, so the next run re-analyzed (and re-paid for) the
114
+ * same memory sets and could double-create summaries. Checkpointing only
115
+ * merges fingerprints — lastRunAt / watermarks remain finalize()'s job.
116
+ */
117
+ private checkpointFingerprints;
110
118
  private addTouched;
111
119
  private recordLLMSkip;
112
120
  private llmCalls;