@stackmemoryai/stackmemory 0.6.3 → 0.8.0

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 (33) hide show
  1. package/dist/src/cli/commands/clear.js +10 -15
  2. package/dist/src/cli/commands/clear.js.map +2 -2
  3. package/dist/src/cli/commands/daemon.js +31 -0
  4. package/dist/src/cli/commands/daemon.js.map +2 -2
  5. package/dist/src/core/context/refactored-frame-manager.js +7 -0
  6. package/dist/src/core/context/refactored-frame-manager.js.map +2 -2
  7. package/dist/src/core/database/database-adapter.js +45 -0
  8. package/dist/src/core/database/database-adapter.js.map +2 -2
  9. package/dist/src/core/database/embedding-provider-factory.js +73 -0
  10. package/dist/src/core/database/embedding-provider-factory.js.map +7 -0
  11. package/dist/src/core/database/ollama-embedding-provider.js +31 -0
  12. package/dist/src/core/database/ollama-embedding-provider.js.map +7 -0
  13. package/dist/src/core/database/openai-embedding-provider.js +44 -0
  14. package/dist/src/core/database/openai-embedding-provider.js.map +7 -0
  15. package/dist/src/core/database/sqlite-adapter.js +419 -21
  16. package/dist/src/core/database/sqlite-adapter.js.map +3 -3
  17. package/dist/src/core/retrieval/context-retriever.js +20 -3
  18. package/dist/src/core/retrieval/context-retriever.js.map +2 -2
  19. package/dist/src/core/session/enhanced-handoff.js +1 -1
  20. package/dist/src/core/session/enhanced-handoff.js.map +2 -2
  21. package/dist/src/core/storage/project-registry.js +61 -0
  22. package/dist/src/core/storage/project-registry.js.map +7 -0
  23. package/dist/src/core/storage/storage-tier-manager.js +214 -0
  24. package/dist/src/core/storage/storage-tier-manager.js.map +7 -0
  25. package/dist/src/daemon/daemon-config.js +11 -0
  26. package/dist/src/daemon/daemon-config.js.map +2 -2
  27. package/dist/src/daemon/services/maintenance-service.js +110 -3
  28. package/dist/src/daemon/services/maintenance-service.js.map +2 -2
  29. package/dist/src/daemon/services/memory-service.js +190 -0
  30. package/dist/src/daemon/services/memory-service.js.map +7 -0
  31. package/dist/src/daemon/unified-daemon.js +39 -5
  32. package/dist/src/daemon/unified-daemon.js.map +2 -2
  33. package/package.json +1 -1
@@ -48,11 +48,14 @@ class SQLiteAdapter extends FeatureAwareDatabaseAdapter {
48
48
  if (config.walMode !== false) {
49
49
  this.db.pragma("journal_mode = WAL");
50
50
  }
51
+ this.db.pragma("mmap_size = 268435456");
51
52
  if (config.busyTimeout) {
52
53
  this.db.pragma(`busy_timeout = ${config.busyTimeout}`);
53
54
  }
54
55
  if (config.cacheSize) {
55
56
  this.db.pragma(`cache_size = ${config.cacheSize}`);
57
+ } else {
58
+ this.db.pragma("cache_size = -64000");
56
59
  }
57
60
  if (config.synchronous) {
58
61
  this.db.pragma(`synchronous = ${config.synchronous}`);
@@ -149,9 +152,55 @@ class SQLiteAdapter extends FeatureAwareDatabaseAdapter {
149
152
  CREATE INDEX IF NOT EXISTS idx_events_seq ON events(frame_id, seq);
150
153
  CREATE INDEX IF NOT EXISTS idx_anchors_frame ON anchors(frame_id);
151
154
 
155
+ -- Composite index for project-scoped time queries (most common access pattern)
156
+ CREATE INDEX IF NOT EXISTS idx_frames_project_created ON frames(project_id, created_at DESC);
157
+
158
+ -- Note: frame_embeddings is a vec0 virtual table with frame_id as PRIMARY KEY;
159
+ -- vec0 handles its own indexing so no CREATE INDEX needed.
160
+
161
+ CREATE TABLE IF NOT EXISTS retrieval_log (
162
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
163
+ query_text TEXT NOT NULL,
164
+ strategy TEXT NOT NULL,
165
+ results_count INTEGER NOT NULL,
166
+ top_score REAL,
167
+ latency_ms INTEGER NOT NULL,
168
+ result_frame_ids TEXT,
169
+ created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
170
+ );
171
+
172
+ CREATE INDEX IF NOT EXISTS idx_retrieval_log_created ON retrieval_log(created_at);
173
+
174
+ CREATE TABLE IF NOT EXISTS maintenance_state (
175
+ key TEXT PRIMARY KEY,
176
+ value TEXT NOT NULL,
177
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
178
+ );
179
+
180
+ CREATE TABLE IF NOT EXISTS project_registry (
181
+ project_id TEXT PRIMARY KEY,
182
+ repo_path TEXT NOT NULL,
183
+ display_name TEXT,
184
+ db_path TEXT NOT NULL,
185
+ is_active INTEGER DEFAULT 0,
186
+ created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
187
+ last_accessed INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
188
+ );
189
+ CREATE INDEX IF NOT EXISTS idx_project_registry_active ON project_registry(is_active);
190
+
152
191
  -- Set initial schema version if not exists
153
192
  INSERT OR IGNORE INTO schema_version (version) VALUES (1);
154
193
  `);
194
+ try {
195
+ this.db.exec(
196
+ "ALTER TABLE frames ADD COLUMN retention_policy TEXT DEFAULT 'default'"
197
+ );
198
+ logger.info("Added retention_policy column to frames");
199
+ } catch {
200
+ }
201
+ this.db.exec(
202
+ "CREATE INDEX IF NOT EXISTS idx_frames_retention_created ON frames(retention_policy, created_at)"
203
+ );
155
204
  try {
156
205
  this.ensureCascadeConstraints();
157
206
  } catch (e) {
@@ -327,6 +376,107 @@ class SQLiteAdapter extends FeatureAwareDatabaseAdapter {
327
376
  this.db.exec("INSERT INTO frames_fts(frames_fts) VALUES('rebuild')");
328
377
  logger.info("FTS5 index rebuilt");
329
378
  }
379
+ /**
380
+ * Incremental garbage collection: delete expired frames and cascade to related tables.
381
+ * Respects retention_policy per frame:
382
+ * - 'keep_forever': never deleted
383
+ * - 'default': deleted after retentionDays (default 90)
384
+ * - 'archive': same as default
385
+ * - 'ttl_30d': deleted after 30 days
386
+ * - 'ttl_7d': deleted after 7 days
387
+ */
388
+ async runGC(options = {}) {
389
+ if (!this.db)
390
+ throw new DatabaseError(
391
+ "Database not connected",
392
+ ErrorCode.DB_CONNECTION_FAILED
393
+ );
394
+ const retentionDays = options.retentionDays ?? 90;
395
+ const batchSize = options.batchSize ?? 100;
396
+ const dryRun = options.dryRun ?? false;
397
+ const nowSec = Math.floor(Date.now() / 1e3);
398
+ const defaultCutoff = nowSec - retentionDays * 86400;
399
+ const ttl30dCutoff = nowSec - 30 * 86400;
400
+ const ttl7dCutoff = nowSec - 7 * 86400;
401
+ const candidates = this.db.prepare(
402
+ `SELECT frame_id FROM frames
403
+ WHERE (
404
+ (retention_policy IN ('default', 'archive') AND created_at < ?)
405
+ OR (retention_policy = 'ttl_30d' AND created_at < ?)
406
+ OR (retention_policy = 'ttl_7d' AND created_at < ?)
407
+ )
408
+ AND retention_policy != 'keep_forever'
409
+ LIMIT ?`
410
+ ).all(defaultCutoff, ttl30dCutoff, ttl7dCutoff, batchSize);
411
+ const frameIds = candidates.map((r) => r.frame_id);
412
+ if (frameIds.length === 0) {
413
+ return {
414
+ framesDeleted: 0,
415
+ eventsDeleted: 0,
416
+ anchorsDeleted: 0,
417
+ embeddingsDeleted: 0,
418
+ ftsEntriesDeleted: 0
419
+ };
420
+ }
421
+ if (dryRun) {
422
+ const placeholders2 = frameIds.map(() => "?").join(",");
423
+ const eventsCount = this.db.prepare(
424
+ `SELECT COUNT(*) as count FROM events WHERE frame_id IN (${placeholders2})`
425
+ ).get(...frameIds).count;
426
+ const anchorsCount = this.db.prepare(
427
+ `SELECT COUNT(*) as count FROM anchors WHERE frame_id IN (${placeholders2})`
428
+ ).get(...frameIds).count;
429
+ let embeddingsCount = 0;
430
+ if (this.vecEnabled) {
431
+ embeddingsCount = this.db.prepare(
432
+ `SELECT COUNT(*) as count FROM frame_embeddings WHERE frame_id IN (${placeholders2})`
433
+ ).get(...frameIds).count;
434
+ }
435
+ return {
436
+ framesDeleted: frameIds.length,
437
+ eventsDeleted: eventsCount,
438
+ anchorsDeleted: anchorsCount,
439
+ embeddingsDeleted: embeddingsCount,
440
+ ftsEntriesDeleted: frameIds.length
441
+ // FTS has one entry per frame
442
+ };
443
+ }
444
+ const placeholders = frameIds.map(() => "?").join(",");
445
+ let eventsDeleted = 0;
446
+ let anchorsDeleted = 0;
447
+ let embeddingsDeleted = 0;
448
+ this.db.prepare("BEGIN").run();
449
+ try {
450
+ if (this.vecEnabled) {
451
+ const embResult = this.db.prepare(
452
+ `DELETE FROM frame_embeddings WHERE frame_id IN (${placeholders})`
453
+ ).run(...frameIds);
454
+ embeddingsDeleted = embResult.changes;
455
+ }
456
+ const evtResult = this.db.prepare(`DELETE FROM events WHERE frame_id IN (${placeholders})`).run(...frameIds);
457
+ eventsDeleted = evtResult.changes;
458
+ const ancResult = this.db.prepare(`DELETE FROM anchors WHERE frame_id IN (${placeholders})`).run(...frameIds);
459
+ anchorsDeleted = ancResult.changes;
460
+ this.db.prepare(`DELETE FROM frames WHERE frame_id IN (${placeholders})`).run(...frameIds);
461
+ this.db.prepare("COMMIT").run();
462
+ } catch (error) {
463
+ this.db.prepare("ROLLBACK").run();
464
+ throw error;
465
+ }
466
+ logger.info("GC completed", {
467
+ framesDeleted: frameIds.length,
468
+ eventsDeleted,
469
+ anchorsDeleted,
470
+ embeddingsDeleted
471
+ });
472
+ return {
473
+ framesDeleted: frameIds.length,
474
+ eventsDeleted,
475
+ anchorsDeleted,
476
+ embeddingsDeleted,
477
+ ftsEntriesDeleted: frameIds.length
478
+ };
479
+ }
330
480
  async migrateSchema(targetVersion) {
331
481
  if (!this.db)
332
482
  throw new DatabaseError(
@@ -588,7 +738,7 @@ class SQLiteAdapter extends FeatureAwareDatabaseAdapter {
588
738
  "Database not connected",
589
739
  ErrorCode.DB_CONNECTION_FAILED
590
740
  );
591
- if (this.ftsEnabled) {
741
+ if (this.ftsEnabled && options.query.trim()) {
592
742
  try {
593
743
  return this.searchFts(options);
594
744
  } catch (e) {
@@ -600,30 +750,50 @@ class SQLiteAdapter extends FeatureAwareDatabaseAdapter {
600
750
  }
601
751
  return this.searchLike(options);
602
752
  }
753
+ /**
754
+ * Sanitize user input for FTS5 MATCH queries.
755
+ * - Strips FTS5 operators and special syntax
756
+ * - Wraps individual terms in double quotes for exact matching
757
+ * - Joins with implicit AND
758
+ * - Supports prefix matching when original query ends with *
759
+ */
760
+ sanitizeFtsQuery(query) {
761
+ const wantsPrefix = query.trimEnd().endsWith("*");
762
+ const cleaned = query.replace(/['"(){}[\]^~*\\,]/g, " ").replace(/\b(AND|OR|NOT|NEAR)\b/gi, "").trim();
763
+ const terms = cleaned.split(/\s+/).filter((t) => t.length > 0);
764
+ if (terms.length === 0) return '""';
765
+ const quoted = terms.map((t) => `"${t}"`);
766
+ if (wantsPrefix) {
767
+ quoted[quoted.length - 1] = quoted[quoted.length - 1] + "*";
768
+ }
769
+ return quoted.join(" ");
770
+ }
603
771
  /**
604
772
  * FTS5 MATCH search with BM25 ranking
605
773
  */
606
774
  searchFts(options) {
775
+ const sanitizedQuery = this.sanitizeFtsQuery(options.query);
607
776
  const boost = options.boost || {};
608
777
  const w0 = boost["name"] || 10;
609
778
  const w1 = boost["digest_text"] || 5;
610
779
  const w2 = boost["inputs"] || 2;
611
780
  const w3 = boost["outputs"] || 1;
781
+ const projectFilter = options.projectId ? "AND f.project_id = ?" : "";
612
782
  const sql = `
613
783
  SELECT f.*, -bm25(frames_fts, ${w0}, ${w1}, ${w2}, ${w3}) as score
614
784
  FROM frames_fts fts
615
785
  JOIN frames f ON f.rowid = fts.rowid
616
786
  WHERE frames_fts MATCH ?
787
+ ${projectFilter}
617
788
  ORDER BY score DESC
618
789
  LIMIT ? OFFSET ?
619
790
  `;
620
791
  const limit = options.limit || 50;
621
792
  const offset = options.offset || 0;
622
- const rows = this.db.prepare(sql).all(
623
- options.query,
624
- limit,
625
- offset
626
- );
793
+ const params = [sanitizedQuery];
794
+ if (options.projectId) params.push(options.projectId);
795
+ params.push(limit, offset);
796
+ const rows = this.db.prepare(sql).all(...params);
627
797
  return rows.map((row) => ({
628
798
  ...row,
629
799
  inputs: JSON.parse(row.inputs || "{}"),
@@ -635,6 +805,7 @@ class SQLiteAdapter extends FeatureAwareDatabaseAdapter {
635
805
  * Fallback LIKE search for when FTS is unavailable
636
806
  */
637
807
  searchLike(options) {
808
+ const projectFilter = options.projectId ? "AND project_id = ?" : "";
638
809
  const sql = `
639
810
  SELECT *,
640
811
  CASE
@@ -644,10 +815,13 @@ class SQLiteAdapter extends FeatureAwareDatabaseAdapter {
644
815
  ELSE 0.5
645
816
  END as score
646
817
  FROM frames
647
- WHERE name LIKE ? OR digest_text LIKE ? OR inputs LIKE ?
818
+ WHERE (name LIKE ? OR digest_text LIKE ? OR inputs LIKE ?)
819
+ ${projectFilter}
648
820
  ORDER BY score DESC
649
821
  `;
650
- const params = Array(6).fill(`%${options.query}%`);
822
+ const likeParam = `%${options.query}%`;
823
+ const params = Array(6).fill(likeParam);
824
+ if (options.projectId) params.push(options.projectId);
651
825
  let rows = this.db.prepare(sql).all(...params);
652
826
  if (options.scoreThreshold) {
653
827
  rows = rows.filter((row) => row.score >= options.scoreThreshold);
@@ -691,32 +865,88 @@ class SQLiteAdapter extends FeatureAwareDatabaseAdapter {
691
865
  digest_json: JSON.parse(row.digest_json || "{}")
692
866
  }));
693
867
  }
694
- async searchHybrid(textQuery, embedding, weights) {
695
- const textWeight = weights?.text ?? 0.6;
696
- const vecWeight = weights?.vector ?? 0.4;
868
+ async searchHybrid(textQuery, embedding, weights, mergeStrategy) {
697
869
  const textResults = await this.search({ query: textQuery, limit: 50 });
698
870
  const vecResults = this.vecEnabled ? await this.searchByVector(embedding, { limit: 50 }) : [];
699
871
  if (vecResults.length === 0) {
700
872
  return textResults;
701
873
  }
874
+ if (textResults.length === 0) {
875
+ return this.normalizeVectorOnly(vecResults);
876
+ }
877
+ if (mergeStrategy === "rrf") {
878
+ return this.mergeByRRF(textResults, vecResults);
879
+ }
880
+ return this.mergeByWeightedScore(textResults, vecResults, weights);
881
+ }
882
+ /**
883
+ * Merge text and vector results using min-max normalized weighted scoring.
884
+ * Both score types are scaled to [0, 1] before combining.
885
+ */
886
+ mergeByWeightedScore(textResults, vecResults, weights) {
887
+ const textWeight = weights?.text ?? 0.6;
888
+ const vecWeight = weights?.vector ?? 0.4;
702
889
  const scoreMap = /* @__PURE__ */ new Map();
703
- const maxText = Math.max(...textResults.map((r) => r.score), 1);
890
+ const textScores = textResults.map((r) => r.score);
891
+ const minText = Math.min(...textScores);
892
+ const maxText = Math.max(...textScores);
893
+ const rangeText = maxText - minText;
704
894
  for (const r of textResults) {
705
- const normalizedScore = r.score / maxText * textWeight;
706
- scoreMap.set(r.frame_id, { frame: r, score: normalizedScore });
895
+ const normalized = rangeText === 0 ? 1 : (r.score - minText) / rangeText;
896
+ scoreMap.set(r.frame_id, { frame: r, score: normalized * textWeight });
707
897
  }
708
- const maxDist = Math.max(...vecResults.map((r) => r.similarity), 1);
898
+ const distances = vecResults.map((r) => r.similarity);
899
+ const minDist = Math.min(...distances);
900
+ const maxDist = Math.max(...distances);
901
+ const rangeDist = maxDist - minDist;
709
902
  for (const r of vecResults) {
710
- const normalizedScore = (1 - r.similarity / maxDist) * vecWeight;
903
+ const normalized = rangeDist === 0 ? 1 : 1 - (r.similarity - minDist) / rangeDist;
711
904
  const existing = scoreMap.get(r.frame_id);
712
905
  if (existing) {
713
- existing.score += normalizedScore;
906
+ existing.score += normalized * vecWeight;
714
907
  } else {
715
- scoreMap.set(r.frame_id, { frame: r, score: normalizedScore });
908
+ scoreMap.set(r.frame_id, { frame: r, score: normalized * vecWeight });
716
909
  }
717
910
  }
718
911
  return Array.from(scoreMap.values()).sort((a, b) => b.score - a.score).map(({ frame, score }) => ({ ...frame, score }));
719
912
  }
913
+ /**
914
+ * Merge text and vector results using Reciprocal Rank Fusion.
915
+ * Rank-based merging that is immune to score scale differences.
916
+ */
917
+ mergeByRRF(textResults, vecResults, k = 60) {
918
+ const scoreMap = /* @__PURE__ */ new Map();
919
+ for (let rank = 0; rank < textResults.length; rank++) {
920
+ const r = textResults[rank];
921
+ const rrfScore = 1 / (k + rank + 1);
922
+ scoreMap.set(r.frame_id, { frame: r, score: rrfScore });
923
+ }
924
+ for (let rank = 0; rank < vecResults.length; rank++) {
925
+ const r = vecResults[rank];
926
+ const rrfScore = 1 / (k + rank + 1);
927
+ const existing = scoreMap.get(r.frame_id);
928
+ if (existing) {
929
+ existing.score += rrfScore;
930
+ } else {
931
+ scoreMap.set(r.frame_id, { frame: r, score: rrfScore });
932
+ }
933
+ }
934
+ return Array.from(scoreMap.values()).sort((a, b) => b.score - a.score).map(({ frame, score }) => ({ ...frame, score }));
935
+ }
936
+ /**
937
+ * Convert vector-only results (distances) to [0, 1] scores.
938
+ */
939
+ normalizeVectorOnly(vecResults) {
940
+ if (vecResults.length === 0) return [];
941
+ const distances = vecResults.map((r) => r.similarity);
942
+ const minDist = Math.min(...distances);
943
+ const maxDist = Math.max(...distances);
944
+ const range = maxDist - minDist;
945
+ return vecResults.map((r) => ({
946
+ ...r,
947
+ score: range === 0 ? 1 : 1 - (r.similarity - minDist) / range
948
+ }));
949
+ }
720
950
  /**
721
951
  * Store an embedding for a frame
722
952
  */
@@ -731,22 +961,52 @@ class SQLiteAdapter extends FeatureAwareDatabaseAdapter {
731
961
  "INSERT OR REPLACE INTO frame_embeddings (frame_id, embedding) VALUES (?, ?)"
732
962
  ).run(frameId, JSON.stringify(embedding));
733
963
  }
964
+ /**
965
+ * Get a maintenance state value by key
966
+ */
967
+ async getMaintenanceState(key) {
968
+ if (!this.db)
969
+ throw new DatabaseError(
970
+ "Database not connected",
971
+ ErrorCode.DB_CONNECTION_FAILED
972
+ );
973
+ const row = this.db.prepare("SELECT value FROM maintenance_state WHERE key = ?").get(key);
974
+ return row?.value ?? null;
975
+ }
976
+ /**
977
+ * Set a maintenance state value by key
978
+ */
979
+ async setMaintenanceState(key, value) {
980
+ if (!this.db)
981
+ throw new DatabaseError(
982
+ "Database not connected",
983
+ ErrorCode.DB_CONNECTION_FAILED
984
+ );
985
+ this.db.prepare(
986
+ "INSERT OR REPLACE INTO maintenance_state (key, value, updated_at) VALUES (?, ?, ?)"
987
+ ).run(key, value, Date.now());
988
+ }
734
989
  /**
735
990
  * Get frames that are missing embeddings
736
991
  */
737
- async getFramesMissingEmbeddings(limit = 50) {
992
+ async getFramesMissingEmbeddings(limit = 50, sinceRowid) {
738
993
  if (!this.db)
739
994
  throw new DatabaseError(
740
995
  "Database not connected",
741
996
  ErrorCode.DB_CONNECTION_FAILED
742
997
  );
998
+ const rowidFilter = sinceRowid != null ? "AND f.rowid > ?" : "";
743
999
  const sql = `
744
1000
  SELECT f.* FROM frames f
745
1001
  LEFT JOIN frame_embeddings ve ON f.frame_id = ve.frame_id
746
- WHERE ve.frame_id IS NULL
1002
+ WHERE ve.frame_id IS NULL ${rowidFilter}
1003
+ ORDER BY f.rowid ASC
747
1004
  LIMIT ?
748
1005
  `;
749
- const rows = this.db.prepare(sql).all(limit);
1006
+ const params = [];
1007
+ if (sinceRowid != null) params.push(sinceRowid);
1008
+ params.push(limit);
1009
+ const rows = this.db.prepare(sql).all(...params);
750
1010
  return rows.map((row) => ({
751
1011
  ...row,
752
1012
  inputs: JSON.parse(row.inputs || "{}"),
@@ -754,6 +1014,84 @@ class SQLiteAdapter extends FeatureAwareDatabaseAdapter {
754
1014
  digest_json: JSON.parse(row.digest_json || "{}")
755
1015
  }));
756
1016
  }
1017
+ // Project registry operations
1018
+ async registerProject(project) {
1019
+ if (!this.db)
1020
+ throw new DatabaseError(
1021
+ "Database not connected",
1022
+ ErrorCode.DB_CONNECTION_FAILED
1023
+ );
1024
+ this.db.prepare(
1025
+ `INSERT OR REPLACE INTO project_registry
1026
+ (project_id, repo_path, display_name, db_path, is_active, created_at, last_accessed)
1027
+ VALUES (?, ?, ?, ?, 0, ?, ?)`
1028
+ ).run(
1029
+ project.projectId,
1030
+ project.repoPath,
1031
+ project.displayName || null,
1032
+ project.dbPath,
1033
+ Date.now(),
1034
+ Date.now()
1035
+ );
1036
+ }
1037
+ async getRegisteredProjects() {
1038
+ if (!this.db)
1039
+ throw new DatabaseError(
1040
+ "Database not connected",
1041
+ ErrorCode.DB_CONNECTION_FAILED
1042
+ );
1043
+ const rows = this.db.prepare("SELECT * FROM project_registry ORDER BY last_accessed DESC").all();
1044
+ return rows.map((row) => ({
1045
+ projectId: row.project_id,
1046
+ repoPath: row.repo_path,
1047
+ displayName: row.display_name,
1048
+ dbPath: row.db_path,
1049
+ isActive: row.is_active === 1,
1050
+ createdAt: row.created_at,
1051
+ lastAccessed: row.last_accessed
1052
+ }));
1053
+ }
1054
+ async setActiveProject(projectId) {
1055
+ if (!this.db)
1056
+ throw new DatabaseError(
1057
+ "Database not connected",
1058
+ ErrorCode.DB_CONNECTION_FAILED
1059
+ );
1060
+ this.db.prepare("UPDATE project_registry SET is_active = 0").run();
1061
+ this.db.prepare(
1062
+ "UPDATE project_registry SET is_active = 1, last_accessed = ? WHERE project_id = ?"
1063
+ ).run(Date.now(), projectId);
1064
+ }
1065
+ async getActiveProject() {
1066
+ if (!this.db)
1067
+ throw new DatabaseError(
1068
+ "Database not connected",
1069
+ ErrorCode.DB_CONNECTION_FAILED
1070
+ );
1071
+ const row = this.db.prepare(
1072
+ "SELECT project_id FROM project_registry WHERE is_active = 1 LIMIT 1"
1073
+ ).get();
1074
+ return row?.project_id ?? null;
1075
+ }
1076
+ async removeProject(projectId) {
1077
+ if (!this.db)
1078
+ throw new DatabaseError(
1079
+ "Database not connected",
1080
+ ErrorCode.DB_CONNECTION_FAILED
1081
+ );
1082
+ const result = this.db.prepare("DELETE FROM project_registry WHERE project_id = ?").run(projectId);
1083
+ return result.changes > 0;
1084
+ }
1085
+ async touchProject(projectId) {
1086
+ if (!this.db)
1087
+ throw new DatabaseError(
1088
+ "Database not connected",
1089
+ ErrorCode.DB_CONNECTION_FAILED
1090
+ );
1091
+ this.db.prepare(
1092
+ "UPDATE project_registry SET last_accessed = ? WHERE project_id = ?"
1093
+ ).run(Date.now(), projectId);
1094
+ }
757
1095
  // Basic aggregation
758
1096
  async aggregate(table, options) {
759
1097
  if (!this.db)
@@ -802,6 +1140,66 @@ class SQLiteAdapter extends FeatureAwareDatabaseAdapter {
802
1140
  lastSeen: new Date(row.last_seen * 1e3)
803
1141
  }));
804
1142
  }
1143
+ // Retrieval logging
1144
+ async logRetrieval(entry) {
1145
+ if (!this.db) return;
1146
+ try {
1147
+ this.db.prepare(
1148
+ `INSERT INTO retrieval_log (query_text, strategy, results_count, top_score, latency_ms, result_frame_ids, created_at)
1149
+ VALUES (?, ?, ?, ?, ?, ?, ?)`
1150
+ ).run(
1151
+ entry.queryText,
1152
+ entry.strategy,
1153
+ entry.resultsCount,
1154
+ entry.topScore,
1155
+ entry.latencyMs,
1156
+ JSON.stringify(entry.resultFrameIds),
1157
+ Date.now()
1158
+ );
1159
+ } catch (e) {
1160
+ logger.warn("Failed to log retrieval", e);
1161
+ }
1162
+ }
1163
+ async getRetrievalStats(sinceDays) {
1164
+ if (!this.db)
1165
+ throw new DatabaseError(
1166
+ "Database not connected",
1167
+ ErrorCode.DB_CONNECTION_FAILED
1168
+ );
1169
+ const sinceMs = sinceDays ? Date.now() - sinceDays * 24 * 60 * 60 * 1e3 : 0;
1170
+ const whereClause = sinceMs ? "WHERE created_at >= ?" : "";
1171
+ const params = sinceMs ? [sinceMs] : [];
1172
+ const agg = this.db.prepare(
1173
+ `SELECT
1174
+ COUNT(*) as total_queries,
1175
+ COALESCE(AVG(latency_ms), 0) as avg_latency_ms,
1176
+ COALESCE(AVG(results_count), 0) as avg_results_count,
1177
+ COUNT(CASE WHEN results_count = 0 THEN 1 END) as queries_with_no_results
1178
+ FROM retrieval_log ${whereClause}`
1179
+ ).get(...params);
1180
+ const p95Offset = Math.max(0, Math.round(agg.total_queries * 0.95) - 1);
1181
+ const p95Row = agg.total_queries > 0 ? this.db.prepare(
1182
+ `SELECT latency_ms FROM retrieval_log ${whereClause}
1183
+ ORDER BY latency_ms ASC
1184
+ LIMIT 1 OFFSET ?`
1185
+ ).get(...params, p95Offset) : void 0;
1186
+ const stratRows = this.db.prepare(
1187
+ `SELECT strategy, COUNT(*) as count FROM retrieval_log ${whereClause}
1188
+ GROUP BY strategy`
1189
+ ).all(...params);
1190
+ const strategyDistribution = {};
1191
+ for (const row of stratRows) {
1192
+ strategyDistribution[row.strategy] = row.count;
1193
+ }
1194
+ return {
1195
+ totalQueries: agg.total_queries,
1196
+ avgLatencyMs: Math.round(agg.avg_latency_ms * 100) / 100,
1197
+ p95LatencyMs: p95Row?.latency_ms ?? 0,
1198
+ strategyDistribution,
1199
+ avgResultsCount: Math.round(agg.avg_results_count * 100) / 100,
1200
+ queriesWithNoResults: agg.queries_with_no_results
1201
+ };
1202
+ }
805
1203
  // Bulk operations
806
1204
  async executeBulk(operations) {
807
1205
  if (!this.db)