@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.
- package/README.md +74 -0
- package/dist/cli-entry.js +49 -0
- package/dist/cli.js +53 -0
- package/dist/commands/backup.js +28 -0
- package/dist/commands/doctor.js +108 -0
- package/dist/commands/help.js +29 -0
- package/dist/commands/index.js +27 -0
- package/dist/commands/init.js +58 -0
- package/dist/commands/migrate.js +25 -0
- package/dist/commands/start.js +29 -0
- package/dist/commands/status.js +19 -0
- package/dist/commands/stop.js +46 -0
- package/dist/commands/version.js +21 -0
- package/dist/core/config.js +161 -0
- package/dist/core/decay.js +50 -0
- package/dist/core/types.js +72 -0
- package/dist/db/database.js +58 -0
- package/dist/db/repositories/access-log-repo.js +59 -0
- package/dist/db/repositories/consolidation-run-repo.js +86 -0
- package/dist/db/repositories/device-repo.js +66 -0
- package/dist/db/repositories/edge-repo.js +104 -0
- package/dist/db/repositories/memory-repo.js +294 -0
- package/dist/db/repositories/observation-repo.js +65 -0
- package/dist/db/repositories/session-repo.js +81 -0
- package/dist/db/repositories/stats-repo.js +92 -0
- package/dist/db/repositories/vector-repo.js +55 -0
- package/dist/db/schema.js +185 -0
- package/dist/injection/bundler.js +39 -0
- package/dist/injection/formatter.js +23 -0
- package/dist/prompts/compression.js +43 -0
- package/dist/prompts/edge-extract.js +21 -0
- package/dist/prompts/value-gate.js +27 -0
- package/dist/providers/embedding/index.js +36 -0
- package/dist/providers/embedding/local-xenova.js +166 -0
- package/dist/providers/embedding/noop.js +40 -0
- package/dist/providers/embedding/openai-compatible.js +46 -0
- package/dist/providers/llm/index.js +12 -0
- package/dist/providers/llm/noop.js +5 -0
- package/dist/providers/llm/openai.js +45 -0
- package/dist/rest/routes/consolidation.js +62 -0
- package/dist/rest/routes/devices.js +47 -0
- package/dist/rest/routes/injection.js +76 -0
- package/dist/rest/routes/memories.js +349 -0
- package/dist/rest/routes/observations.js +29 -0
- package/dist/rest/routes/sessions.js +37 -0
- package/dist/rest/routes/settings.js +25 -0
- package/dist/rest/routes/stats.js +15 -0
- package/dist/retrieval/bm25-search.js +91 -0
- package/dist/retrieval/causal-chain.js +197 -0
- package/dist/retrieval/fusion.js +48 -0
- package/dist/retrieval/graph-traversal.js +144 -0
- package/dist/retrieval/search-engine.js +150 -0
- package/dist/retrieval/vector-search.js +91 -0
- package/dist/server/auth.js +80 -0
- package/dist/server/bootstrap.js +28 -0
- package/dist/server/http.js +77 -0
- package/dist/server/logger.js +36 -0
- package/dist/server/rate-limiter.js +81 -0
- package/dist/server/scheduler.js +99 -0
- package/dist/workers/association.js +41 -0
- package/dist/workers/compressor.js +14 -0
- package/dist/workers/consolidator.js +201 -0
- package/dist/workers/embedder.js +102 -0
- package/dist/workers/graph-worker.js +166 -0
- package/dist/workers/value-gate.js +38 -0
- 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
|
+
}
|