@stackmemoryai/stackmemory 0.6.3 → 0.7.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.
- package/dist/src/core/database/database-adapter.js +45 -0
- package/dist/src/core/database/database-adapter.js.map +2 -2
- package/dist/src/core/database/embedding-provider-factory.js +73 -0
- package/dist/src/core/database/embedding-provider-factory.js.map +7 -0
- package/dist/src/core/database/ollama-embedding-provider.js +31 -0
- package/dist/src/core/database/ollama-embedding-provider.js.map +7 -0
- package/dist/src/core/database/openai-embedding-provider.js +44 -0
- package/dist/src/core/database/openai-embedding-provider.js.map +7 -0
- package/dist/src/core/database/sqlite-adapter.js +418 -20
- package/dist/src/core/database/sqlite-adapter.js.map +3 -3
- package/dist/src/core/retrieval/context-retriever.js +20 -3
- package/dist/src/core/retrieval/context-retriever.js.map +2 -2
- package/dist/src/core/storage/project-registry.js +61 -0
- package/dist/src/core/storage/project-registry.js.map +7 -0
- package/dist/src/core/storage/storage-tier-manager.js +214 -0
- package/dist/src/core/storage/storage-tier-manager.js.map +7 -0
- package/dist/src/daemon/daemon-config.js.map +2 -2
- package/dist/src/daemon/services/maintenance-service.js +110 -3
- package/dist/src/daemon/services/maintenance-service.js.map +2 -2
- package/dist/src/daemon/unified-daemon.js +6 -2
- package/dist/src/daemon/unified-daemon.js.map +2 -2
- 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(
|
|
@@ -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
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
|
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
|
|
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
|
|
706
|
-
scoreMap.set(r.frame_id, { frame: r, score:
|
|
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
|
|
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
|
|
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 +=
|
|
906
|
+
existing.score += normalized * vecWeight;
|
|
714
907
|
} else {
|
|
715
|
-
scoreMap.set(r.frame_id, { frame: r, score:
|
|
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
|
|
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)
|