@mem-weave/server 0.2.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 (66) hide show
  1. package/README.md +74 -0
  2. package/dist/cli-entry.js +49 -0
  3. package/dist/cli.js +53 -0
  4. package/dist/commands/backup.js +28 -0
  5. package/dist/commands/doctor.js +108 -0
  6. package/dist/commands/help.js +29 -0
  7. package/dist/commands/index.js +27 -0
  8. package/dist/commands/init.js +58 -0
  9. package/dist/commands/migrate.js +25 -0
  10. package/dist/commands/start.js +29 -0
  11. package/dist/commands/status.js +19 -0
  12. package/dist/commands/stop.js +46 -0
  13. package/dist/commands/version.js +21 -0
  14. package/dist/core/config.js +161 -0
  15. package/dist/core/decay.js +50 -0
  16. package/dist/core/types.js +72 -0
  17. package/dist/db/database.js +58 -0
  18. package/dist/db/repositories/access-log-repo.js +59 -0
  19. package/dist/db/repositories/consolidation-run-repo.js +86 -0
  20. package/dist/db/repositories/device-repo.js +66 -0
  21. package/dist/db/repositories/edge-repo.js +104 -0
  22. package/dist/db/repositories/memory-repo.js +294 -0
  23. package/dist/db/repositories/observation-repo.js +65 -0
  24. package/dist/db/repositories/session-repo.js +81 -0
  25. package/dist/db/repositories/stats-repo.js +92 -0
  26. package/dist/db/repositories/vector-repo.js +55 -0
  27. package/dist/db/schema.js +185 -0
  28. package/dist/injection/bundler.js +39 -0
  29. package/dist/injection/formatter.js +23 -0
  30. package/dist/prompts/compression.js +43 -0
  31. package/dist/prompts/edge-extract.js +21 -0
  32. package/dist/prompts/value-gate.js +27 -0
  33. package/dist/providers/embedding/index.js +36 -0
  34. package/dist/providers/embedding/local-xenova.js +166 -0
  35. package/dist/providers/embedding/noop.js +40 -0
  36. package/dist/providers/embedding/openai-compatible.js +46 -0
  37. package/dist/providers/llm/index.js +12 -0
  38. package/dist/providers/llm/noop.js +5 -0
  39. package/dist/providers/llm/openai.js +45 -0
  40. package/dist/rest/routes/consolidation.js +62 -0
  41. package/dist/rest/routes/devices.js +47 -0
  42. package/dist/rest/routes/injection.js +76 -0
  43. package/dist/rest/routes/memories.js +349 -0
  44. package/dist/rest/routes/observations.js +29 -0
  45. package/dist/rest/routes/sessions.js +37 -0
  46. package/dist/rest/routes/settings.js +25 -0
  47. package/dist/rest/routes/stats.js +15 -0
  48. package/dist/retrieval/bm25-search.js +91 -0
  49. package/dist/retrieval/causal-chain.js +197 -0
  50. package/dist/retrieval/fusion.js +48 -0
  51. package/dist/retrieval/graph-traversal.js +144 -0
  52. package/dist/retrieval/search-engine.js +150 -0
  53. package/dist/retrieval/vector-search.js +91 -0
  54. package/dist/server/auth.js +80 -0
  55. package/dist/server/bootstrap.js +28 -0
  56. package/dist/server/http.js +77 -0
  57. package/dist/server/logger.js +36 -0
  58. package/dist/server/rate-limiter.js +81 -0
  59. package/dist/server/scheduler.js +99 -0
  60. package/dist/workers/association.js +41 -0
  61. package/dist/workers/compressor.js +14 -0
  62. package/dist/workers/consolidator.js +201 -0
  63. package/dist/workers/embedder.js +102 -0
  64. package/dist/workers/graph-worker.js +166 -0
  65. package/dist/workers/value-gate.js +38 -0
  66. package/package.json +40 -0
@@ -0,0 +1,294 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { initialStrengthFromImportance, reinforcementBoost, tauFor } from '../../core/decay.js';
3
+ const DEDUP_JACCARD_THRESHOLD = 0.8;
4
+ export class MemoryRepo {
5
+ db;
6
+ constructor(db) {
7
+ this.db = db;
8
+ }
9
+ create(input) {
10
+ return this.createDetailed(input).memory;
11
+ }
12
+ /**
13
+ * Same as `create`, but also reports whether the insert was a dedup
14
+ * hit. REST routes that want to show "merged with X" UI should use this.
15
+ */
16
+ createDetailed(input) {
17
+ // ── Dedup gate (server-side, LLM never knows) ─────────────────────────
18
+ // BM25 against the FTS5 index using title + summary + concepts. Zero
19
+ // embedding cost. Top-3 candidates are scored by Jaccard similarity on
20
+ // the concepts set. A hit means "this is a near-duplicate of an
21
+ // existing memory" → reinforce the existing one instead of inserting
22
+ // a new row. The existing memory's `last_reinforced_at` bumps, its
23
+ // `access_count` increments, and if the new content is richer we
24
+ // upgrade importance + content.
25
+ const dup = this.findNearDuplicate(input);
26
+ if (dup) {
27
+ const reinforced = this.reinforceExisting(dup, input);
28
+ return { memory: reinforced, deduped: true, reinforcedId: dup.id };
29
+ }
30
+ const now = Date.now();
31
+ const id = randomUUID();
32
+ const tier = input.importance >= 10 ? 'long' : input.importance >= 7 && input.confidence > 0.75 ? 'medium' : 'short';
33
+ const strength = initialStrengthFromImportance(input.importance);
34
+ const tau = tauFor(tier, input.importance);
35
+ const conceptsJson = JSON.stringify(input.concepts);
36
+ const filesJson = JSON.stringify(input.files);
37
+ const conceptsText = input.concepts.join(' ');
38
+ const tx = this.db.transaction(() => {
39
+ this.db.prepare(`
40
+ INSERT INTO memories (
41
+ id, tenant_id, tier, type, title, content, summary,
42
+ concepts_json, concepts_text, files_json, importance, confidence,
43
+ strength, source, scope_level, source_client, source_device_id,
44
+ source_session_id, tau, access_count, last_accessed_at,
45
+ last_reinforced_at, last_decay_at, reinforcement_score,
46
+ promoted_at, created_at, updated_at, deleted_at, eviction_reason
47
+ ) VALUES (
48
+ @id, @tenantId, @tier, @type, @title, @content, @summary,
49
+ @conceptsJson, @conceptsText, @filesJson, @importance, @confidence,
50
+ @strength, @source, @scopeLevel, @sourceClient, @sourceDeviceId,
51
+ @sourceSessionId, @tau, 0, NULL, NULL, @now, 0,
52
+ NULL, @now, @now, NULL, NULL
53
+ )
54
+ `).run({ ...input, id, tier, strength, tau, conceptsJson, conceptsText, filesJson, now });
55
+ const scopeStmt = this.db.prepare(`
56
+ INSERT INTO memory_scopes (memory_id, tenant_id, key, value, created_at)
57
+ VALUES (?, ?, ?, ?, ?)
58
+ `);
59
+ for (const scope of input.scopes)
60
+ scopeStmt.run(id, input.tenantId, scope.key, scope.value, now);
61
+ });
62
+ tx();
63
+ const created = this.getById(input.tenantId, id);
64
+ if (!created)
65
+ throw new Error(`Failed to create memory ${id}`);
66
+ return { memory: created, deduped: false };
67
+ }
68
+ getById(tenantId, id) {
69
+ const row = this.db.prepare('SELECT * FROM memories WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL')
70
+ .get(tenantId, id);
71
+ if (!row)
72
+ return null;
73
+ const scopes = this.db.prepare('SELECT key, value FROM memory_scopes WHERE tenant_id = ? AND memory_id = ? ORDER BY key, value')
74
+ .all(tenantId, id);
75
+ return this.mapRow(row, scopes);
76
+ }
77
+ recordAccess(input) {
78
+ const now = Date.now();
79
+ const id = randomUUID();
80
+ const boost = reinforcementBoost({
81
+ usedInContext: input.usedInContext,
82
+ explicitReference: false,
83
+ userConfirmed: false
84
+ });
85
+ const tx = this.db.transaction(() => {
86
+ this.db.prepare(`
87
+ INSERT INTO access_logs (
88
+ id, tenant_id, memory_id, session_id, device_id,
89
+ source, query, rank, score, used_in_context, accessed_at
90
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
91
+ `).run(id, input.tenantId, input.memoryId, input.sessionId, input.deviceId, input.source, input.query, input.rank, input.score, input.usedInContext ? 1 : 0, now);
92
+ this.db.prepare(`
93
+ UPDATE memories
94
+ SET access_count = access_count + 1,
95
+ last_accessed_at = ?,
96
+ last_reinforced_at = CASE WHEN ? >= 0.1 THEN ? ELSE last_reinforced_at END,
97
+ reinforcement_score = min(1, reinforcement_score + ?),
98
+ strength = min(1, strength + ?),
99
+ updated_at = ?
100
+ WHERE tenant_id = ? AND id = ?
101
+ `).run(now, boost, now, boost, boost, now, input.tenantId, input.memoryId);
102
+ });
103
+ tx();
104
+ }
105
+ // ── internals ────────────────────────────────────────────────────────────
106
+ /**
107
+ * Find a near-duplicate of `input` already in the DB. Returns the
108
+ * candidate memory, or `null` if none qualifies.
109
+ *
110
+ * Strategy:
111
+ * 1. BM25 over `memory_fts` using the input's concepts as the query
112
+ * (no embedding cost; FTS5 is < 1ms for a few MB of memories).
113
+ * We OR the concepts (any-concept match) so we get a candidate set.
114
+ * 2. Take top-5 candidates within the same tenant.
115
+ * 3. For each, compute Jaccard similarity on the concepts set.
116
+ * 4. If best Jaccard >= DEDUP_JACCARD_THRESHOLD AND same `type`,
117
+ * that's a duplicate.
118
+ *
119
+ * Why OR not AND on concepts? The new save's concepts might not all
120
+ * appear in the old memory (e.g. new concept added). We want a
121
+ * candidate pool to score with Jaccard, not an exact match.
122
+ * Why not BM25 score alone? FTS5 ranks by term frequency, not by
123
+ * "this is the same fact". A memory about "TypeScript" would BM25-match
124
+ * a memory about "TypeScript generics" highly, but they're not duplicates.
125
+ * Concepts-based Jaccard catches the actual semantic overlap.
126
+ */
127
+ findNearDuplicate(input) {
128
+ // Query with concepts only — title is too noisy. Sanitize to FTS5-safe
129
+ // tokens (lowercase, only word chars and hyphens/underscores).
130
+ const queryTokens = input.concepts
131
+ .map((c) => c.toLowerCase())
132
+ .filter((t) => t.length > 0 && /^[a-z0-9_-]+$/.test(t));
133
+ if (queryTokens.length === 0)
134
+ return null;
135
+ // OR-join: any concept matching is enough to surface a candidate.
136
+ const ftsQuery = queryTokens.map((t) => `"${t}"`).join(' OR ');
137
+ let rows;
138
+ try {
139
+ rows = this.db.prepare(`
140
+ SELECT m.id AS id, m.type AS type, m.concepts_json AS concepts
141
+ FROM memory_fts f
142
+ JOIN memories m ON m.rowid = f.rowid
143
+ WHERE memory_fts MATCH ?
144
+ AND m.tenant_id = ?
145
+ AND m.deleted_at IS NULL
146
+ ORDER BY rank
147
+ LIMIT 5
148
+ `).all(ftsQuery, input.tenantId);
149
+ }
150
+ catch {
151
+ // FTS5 syntax error (shouldn't happen with our sanitized query, but
152
+ // defend anyway). Fall through to "no dedup hit".
153
+ return null;
154
+ }
155
+ if (rows.length === 0)
156
+ return null;
157
+ const inputConcepts = new Set(input.concepts.map((c) => c.toLowerCase()));
158
+ let bestMatch = null;
159
+ for (const row of rows) {
160
+ // Type must match — a "fact" is never a duplicate of a "decision".
161
+ if (row.type !== input.type)
162
+ continue;
163
+ const existingConcepts = new Set(JSON.parse(row.concepts).map((c) => c.toLowerCase()));
164
+ const jaccard = jaccardSimilarity(inputConcepts, existingConcepts);
165
+ if (jaccard >= DEDUP_JACCARD_THRESHOLD && (!bestMatch || jaccard > bestMatch.score)) {
166
+ const mem = this.getById(input.tenantId, row.id);
167
+ if (mem)
168
+ bestMatch = { memory: mem, score: jaccard };
169
+ }
170
+ }
171
+ return bestMatch?.memory ?? null;
172
+ }
173
+ /**
174
+ * Bump an existing memory's reinforcement signals and, if the incoming
175
+ * input carries more information (longer content, higher importance),
176
+ * upgrade the existing record's content + importance. Returns the
177
+ * updated memory.
178
+ *
179
+ * Writes an `access_logs` row with `source: 'dedup_reinforce'` so the
180
+ * audit trail is consistent with `recordAccess()` — operators can
181
+ * see "this memory was reinforced by a dedup hit" the same way they
182
+ * see regular retrievals.
183
+ */
184
+ reinforceExisting(existing, incoming) {
185
+ const now = Date.now();
186
+ const boost = reinforcementBoost({
187
+ usedInContext: true, // LLM just saw it via injection → count as used
188
+ explicitReference: false,
189
+ userConfirmed: false
190
+ });
191
+ // Decide whether to merge content. If incoming is strictly longer and
192
+ // has higher importance, upgrade. Otherwise just bump reinforcement
193
+ // and keep the existing content (it's already good enough).
194
+ const incomingIsRicher = incoming.content.length > existing.content.length * 1.25 ||
195
+ incoming.importance > existing.importance;
196
+ const tx = this.db.transaction(() => {
197
+ if (incomingIsRicher) {
198
+ // Merge: take the longer content + the max importance + the union of concepts + files
199
+ const mergedConcepts = Array.from(new Set([...existing.concepts, ...incoming.concepts]));
200
+ const mergedFiles = Array.from(new Set([...existing.files, ...incoming.files]));
201
+ const newImportance = Math.max(existing.importance, incoming.importance);
202
+ this.db.prepare(`
203
+ UPDATE memories
204
+ SET content = ?,
205
+ concepts_json = ?,
206
+ concepts_text = ?,
207
+ files_json = ?,
208
+ importance = ?,
209
+ access_count = access_count + 1,
210
+ last_accessed_at = ?,
211
+ last_reinforced_at = ?,
212
+ reinforcement_score = min(1, reinforcement_score + ?),
213
+ strength = min(1, strength + ?),
214
+ updated_at = ?
215
+ WHERE tenant_id = ? AND id = ?
216
+ `).run(incoming.content, JSON.stringify(mergedConcepts), mergedConcepts.join(' '), JSON.stringify(mergedFiles), newImportance, now, now, boost, boost, now, existing.tenantId, existing.id);
217
+ }
218
+ else {
219
+ this.db.prepare(`
220
+ UPDATE memories
221
+ SET access_count = access_count + 1,
222
+ last_accessed_at = ?,
223
+ last_reinforced_at = ?,
224
+ reinforcement_score = min(1, reinforcement_score + ?),
225
+ strength = min(1, strength + ?),
226
+ updated_at = ?
227
+ WHERE tenant_id = ? AND id = ?
228
+ `).run(now, now, boost, boost, now, existing.tenantId, existing.id);
229
+ }
230
+ // Audit log row — same shape as recordAccess() emits, so the
231
+ // /api/v1/memories/:id/access-logs endpoint surfaces dedup
232
+ // reinforcements uniformly with retrieval accesses.
233
+ this.db.prepare(`
234
+ INSERT INTO access_logs (
235
+ id, tenant_id, memory_id, session_id, device_id,
236
+ source, query, rank, score, used_in_context, accessed_at
237
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
238
+ `).run(randomUUID(), existing.tenantId, existing.id, incoming.sourceSessionId, incoming.sourceDeviceId, 'dedup_reinforce', null, null, null, 1, // usedInContext = true (the LLM was about to use it)
239
+ now);
240
+ });
241
+ tx();
242
+ const updated = this.getById(existing.tenantId, existing.id);
243
+ if (!updated)
244
+ throw new Error(`Failed to reinforce memory ${existing.id}`);
245
+ return updated;
246
+ }
247
+ mapRow(row, scopes) {
248
+ return {
249
+ id: row.id,
250
+ tenantId: row.tenant_id,
251
+ tier: row.tier,
252
+ type: row.type,
253
+ title: row.title,
254
+ content: row.content,
255
+ summary: row.summary,
256
+ concepts: JSON.parse(row.concepts_json),
257
+ files: JSON.parse(row.files_json),
258
+ importance: row.importance,
259
+ confidence: row.confidence,
260
+ strength: row.strength,
261
+ source: row.source,
262
+ scopeLevel: row.scope_level,
263
+ scopes,
264
+ sourceClient: row.source_client,
265
+ sourceDeviceId: row.source_device_id,
266
+ sourceSessionId: row.source_session_id,
267
+ tau: row.tau,
268
+ accessCount: row.access_count,
269
+ lastAccessedAt: row.last_accessed_at,
270
+ lastReinforcedAt: row.last_reinforced_at,
271
+ lastDecayAt: row.last_decay_at,
272
+ reinforcementScore: row.reinforcement_score,
273
+ promotedAt: row.promoted_at,
274
+ createdAt: row.created_at,
275
+ updatedAt: row.updated_at,
276
+ deletedAt: row.deleted_at,
277
+ evictionReason: row.eviction_reason
278
+ };
279
+ }
280
+ }
281
+ /**
282
+ * Jaccard similarity: |A ∩ B| / |A ∪ B|. Returns 0 if both sets are empty
283
+ * (we treat "no concepts" as "not similar to anything").
284
+ */
285
+ function jaccardSimilarity(a, b) {
286
+ if (a.size === 0 && b.size === 0)
287
+ return 0;
288
+ let intersection = 0;
289
+ for (const x of a)
290
+ if (b.has(x))
291
+ intersection++;
292
+ const union = a.size + b.size - intersection;
293
+ return union === 0 ? 0 : intersection / union;
294
+ }
@@ -0,0 +1,65 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ export class ObservationRepo {
3
+ db;
4
+ constructor(db) {
5
+ this.db = db;
6
+ }
7
+ create(input) {
8
+ const id = randomUUID();
9
+ const now = Date.now();
10
+ this.db.prepare(`
11
+ INSERT INTO observations (id, session_id, tenant_id, hook_type, tool_name, tool_input, tool_output, timestamp, memory_id, processed)
12
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
13
+ `).run(id, input.sessionId, input.tenantId, input.hookType, input.toolName, input.toolInput, input.toolOutput, now, input.memoryId);
14
+ return {
15
+ id,
16
+ sessionId: input.sessionId,
17
+ tenantId: input.tenantId,
18
+ hookType: input.hookType,
19
+ toolName: input.toolName,
20
+ toolInput: input.toolInput,
21
+ toolOutput: input.toolOutput,
22
+ timestamp: now,
23
+ memoryId: input.memoryId,
24
+ processed: false
25
+ };
26
+ }
27
+ getById(tenantId, id) {
28
+ const row = this.db.prepare(`
29
+ SELECT * FROM observations WHERE tenant_id = ? AND id = ?
30
+ `).get(tenantId, id);
31
+ if (!row)
32
+ return null;
33
+ return this.mapRow(row);
34
+ }
35
+ listUnprocessed(tenantId, limit) {
36
+ if (limit <= 0)
37
+ return [];
38
+ const rows = this.db.prepare(`
39
+ SELECT * FROM observations
40
+ WHERE tenant_id = ? AND processed = 0
41
+ ORDER BY timestamp ASC, rowid ASC
42
+ LIMIT ?
43
+ `).all(tenantId, limit);
44
+ return rows.map((r) => this.mapRow(r));
45
+ }
46
+ markProcessed(id, memoryId) {
47
+ this.db.prepare(`
48
+ UPDATE observations SET processed = 1, memory_id = ? WHERE id = ?
49
+ `).run(memoryId, id);
50
+ }
51
+ mapRow(row) {
52
+ return {
53
+ id: row.id,
54
+ sessionId: row.session_id,
55
+ tenantId: row.tenant_id,
56
+ hookType: row.hook_type,
57
+ toolName: row.tool_name,
58
+ toolInput: row.tool_input,
59
+ toolOutput: row.tool_output,
60
+ timestamp: row.timestamp,
61
+ memoryId: row.memory_id,
62
+ processed: row.processed === 1
63
+ };
64
+ }
65
+ }
@@ -0,0 +1,81 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ export class SessionRepo {
3
+ db;
4
+ constructor(db) {
5
+ this.db = db;
6
+ }
7
+ create(input) {
8
+ const id = randomUUID();
9
+ const now = Date.now();
10
+ this.db.prepare(`
11
+ INSERT INTO sessions (id, tenant_id, device_id, source, title, summary, started_at, ended_at, observation_count)
12
+ VALUES (?, ?, ?, ?, ?, NULL, ?, NULL, 0)
13
+ `).run(id, input.tenantId, input.deviceId, input.source, input.title, now);
14
+ return {
15
+ id,
16
+ tenantId: input.tenantId,
17
+ deviceId: input.deviceId,
18
+ source: input.source,
19
+ title: input.title,
20
+ summary: null,
21
+ startedAt: now,
22
+ endedAt: null,
23
+ observationCount: 0
24
+ };
25
+ }
26
+ getById(tenantId, id) {
27
+ const row = this.db.prepare(`
28
+ SELECT * FROM sessions WHERE tenant_id = ? AND id = ?
29
+ `).get(tenantId, id);
30
+ if (!row)
31
+ return null;
32
+ return this.mapRow(row);
33
+ }
34
+ listRecent(tenantId, limit) {
35
+ if (limit <= 0)
36
+ return [];
37
+ const rows = this.db.prepare(`
38
+ SELECT * FROM sessions
39
+ WHERE tenant_id = ?
40
+ ORDER BY started_at DESC, rowid DESC
41
+ LIMIT ?
42
+ `).all(tenantId, limit);
43
+ return rows.map((r) => this.mapRow(r));
44
+ }
45
+ end(id) {
46
+ this.db.prepare(`
47
+ UPDATE sessions SET ended_at = ? WHERE id = ?
48
+ `).run(Date.now(), id);
49
+ }
50
+ listMemories(tenantId, sessionId) {
51
+ const rows = this.db.prepare(`
52
+ SELECT id, type, tier, title, summary, strength, importance, created_at
53
+ FROM memories
54
+ WHERE tenant_id = ? AND source_session_id = ? AND deleted_at IS NULL
55
+ ORDER BY created_at DESC
56
+ `).all(tenantId, sessionId);
57
+ return rows.map((r) => ({
58
+ id: r.id,
59
+ type: r.type,
60
+ tier: r.tier,
61
+ title: r.title,
62
+ summary: r.summary,
63
+ strength: r.strength,
64
+ importance: r.importance,
65
+ createdAt: r.created_at
66
+ }));
67
+ }
68
+ mapRow(row) {
69
+ return {
70
+ id: row.id,
71
+ tenantId: row.tenant_id,
72
+ deviceId: row.device_id,
73
+ source: row.source,
74
+ title: row.title,
75
+ summary: row.summary,
76
+ startedAt: row.started_at,
77
+ endedAt: row.ended_at,
78
+ observationCount: row.observation_count
79
+ };
80
+ }
81
+ }
@@ -0,0 +1,92 @@
1
+ import { ConsolidationRunRepo } from './consolidation-run-repo.js';
2
+ const DAY_MS = 24 * 60 * 60 * 1000;
3
+ export class StatsRepo {
4
+ db;
5
+ constructor(db) {
6
+ this.db = db;
7
+ }
8
+ getStats(tenantId) {
9
+ const now = Date.now();
10
+ const startOfDay = now - (now % DAY_MS);
11
+ // Totals
12
+ const totalsRow = this.db.prepare(`
13
+ SELECT
14
+ (SELECT COUNT(*) FROM memories WHERE tenant_id = ?) AS memories,
15
+ (SELECT COUNT(*) FROM memories WHERE tenant_id = ? AND deleted_at IS NULL) AS activeMemories,
16
+ (SELECT COUNT(*) FROM sessions WHERE tenant_id = ?) AS sessions,
17
+ (SELECT COUNT(*) FROM observations WHERE tenant_id = ?) AS observations,
18
+ (SELECT COUNT(*) FROM edges WHERE tenant_id = ?) AS edges,
19
+ (SELECT COUNT(*) FROM devices WHERE tenant_id = ?) AS devices
20
+ `).get(tenantId, tenantId, tenantId, tenantId, tenantId, tenantId);
21
+ // By tier (non-deleted only)
22
+ const tierRows = this.db.prepare(`
23
+ SELECT tier, COUNT(*) AS c
24
+ FROM memories WHERE tenant_id = ? AND deleted_at IS NULL
25
+ GROUP BY tier
26
+ `).all(tenantId);
27
+ const byTier = { short: 0, medium: 0, long: 0 };
28
+ for (const r of tierRows)
29
+ byTier[r.tier] = r.c;
30
+ // By type (non-deleted only)
31
+ const typeRows = this.db.prepare(`
32
+ SELECT type, COUNT(*) AS c
33
+ FROM memories WHERE tenant_id = ? AND deleted_at IS NULL
34
+ GROUP BY type
35
+ `).all(tenantId);
36
+ const byType = {
37
+ fact: 0, decision: 0, preference: 0, event: 0, project_context: 0,
38
+ lesson: 0, code_pattern: 0, bug: 0, workflow: 0
39
+ };
40
+ for (const r of typeRows)
41
+ byType[r.type] = r.c;
42
+ // Today
43
+ const todayNewRow = this.db.prepare(`
44
+ SELECT COUNT(*) AS c FROM memories
45
+ WHERE tenant_id = ? AND created_at >= ?
46
+ `).get(tenantId, startOfDay);
47
+ // Recent projects: pull from memory_scopes
48
+ const projectRows = this.db.prepare(`
49
+ SELECT value AS project, COUNT(DISTINCT memory_id) AS c
50
+ FROM memory_scopes
51
+ WHERE tenant_id = ? AND key = 'project'
52
+ GROUP BY value
53
+ ORDER BY c DESC
54
+ LIMIT 10
55
+ `).all(tenantId);
56
+ const recentProjects = projectRows.map((r) => ({ project: r.project, count: r.c }));
57
+ // Last consolidation
58
+ const runRepo = new ConsolidationRunRepo(this.db);
59
+ const latestRun = runRepo.latestForTenant(tenantId);
60
+ const lastConsolidation = latestRun
61
+ ? { id: latestRun.id, startedAt: latestRun.startedAt, summary: latestRun.summary }
62
+ : null;
63
+ // Today consolidation stats
64
+ const todayConsRow = this.db.prepare(`
65
+ SELECT
66
+ COALESCE(SUM(promoted_count), 0) AS promoted,
67
+ COALESCE(SUM(evicted_count), 0) AS evicted
68
+ FROM consolidation_runs
69
+ WHERE tenant_id = ? AND started_at >= ?
70
+ `).get(tenantId, startOfDay);
71
+ return {
72
+ totals: {
73
+ memories: totalsRow.memories,
74
+ activeMemories: totalsRow.activeMemories,
75
+ sessions: totalsRow.sessions,
76
+ observations: totalsRow.observations,
77
+ edges: totalsRow.edges,
78
+ devices: totalsRow.devices
79
+ },
80
+ byTier,
81
+ byType,
82
+ today: {
83
+ promoted: todayConsRow.promoted,
84
+ evicted: todayConsRow.evicted,
85
+ newMemories: todayNewRow.c,
86
+ injectBundles: 0 // v1: not tracked
87
+ },
88
+ recentProjects,
89
+ lastConsolidation
90
+ };
91
+ }
92
+ }
@@ -0,0 +1,55 @@
1
+ import { getVecTableName, VECTOR_DEFAULT_DIMENSIONS } from '../database.js';
2
+ export class VectorRepo {
3
+ db;
4
+ dimensions;
5
+ constructor(db, dimensions = VECTOR_DEFAULT_DIMENSIONS) {
6
+ this.db = db;
7
+ this.dimensions = dimensions;
8
+ }
9
+ /**
10
+ * Upsert a single embedding. Inserts a new row or replaces an existing one
11
+ * (matching by memory_id).
12
+ */
13
+ upsert(memoryId, tenantId, vector) {
14
+ if (vector.length !== this.dimensions) {
15
+ throw new Error(`Vector dimensions mismatch: expected ${this.dimensions}, got ${vector.length}`);
16
+ }
17
+ const tableName = getVecTableName(this.dimensions);
18
+ const exists = this.db
19
+ .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`)
20
+ .get(tableName);
21
+ if (!exists) {
22
+ // Vec table not present (sqlite-vec unavailable); silently no-op.
23
+ return;
24
+ }
25
+ // sqlite-vec's vec0 primary-key behavior is rowid-based; use a manual
26
+ // delete-then-insert to guarantee a clean replacement. We wrap in a
27
+ // transaction for atomicity.
28
+ const tx = this.db.transaction(() => {
29
+ this.db.prepare(`DELETE FROM ${tableName} WHERE memory_id = ?`).run(memoryId);
30
+ this.db.prepare(`
31
+ INSERT INTO ${tableName} (memory_id, tenant_id, embedding)
32
+ VALUES (?, ?, ?)
33
+ `).run(memoryId, tenantId, new Float32Array(vector));
34
+ });
35
+ tx();
36
+ }
37
+ /** Remove the embedding for a memory. */
38
+ delete(memoryId) {
39
+ const tableName = getVecTableName(this.dimensions);
40
+ this.db.prepare(`
41
+ DELETE FROM ${tableName} WHERE memory_id = ?
42
+ `).run(memoryId);
43
+ }
44
+ /** Count embeddings in the vec table. */
45
+ count() {
46
+ const tableName = getVecTableName(this.dimensions);
47
+ const exists = this.db
48
+ .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`)
49
+ .get(tableName);
50
+ if (!exists)
51
+ return 0;
52
+ const row = this.db.prepare(`SELECT COUNT(*) as cnt FROM ${tableName}`).get();
53
+ return row.cnt;
54
+ }
55
+ }