@slorenzot/memento-core 0.7.0 → 1.0.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/MemoryEngine.d.ts +24 -8
- package/dist/MemoryEngine.d.ts.map +1 -1
- package/dist/MemoryEngine.js +441 -23
- package/dist/MemoryEngine.js.map +1 -1
- package/dist/db/schema.d.ts +12 -0
- package/dist/db/schema.d.ts.map +1 -1
- package/dist/db/schema.js +1 -0
- package/dist/db/schema.js.map +1 -1
- package/dist/types.d.ts +42 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -3
- package/src/MemoryEngine.ts +564 -33
- package/src/db/schema.ts +1 -0
- package/src/types.ts +54 -0
package/dist/MemoryEngine.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Observation, Session, Prompt } from './types.js';
|
|
1
|
+
import type { Observation, Session, Prompt, SearchParams, SearchResult, MergeParams, MergeResult, MergeCandidates, ExportParams, ExportResult } from './types.js';
|
|
2
2
|
export declare class MemoryEngine {
|
|
3
3
|
private db;
|
|
4
4
|
private dbPath;
|
|
@@ -28,19 +28,35 @@ export declare class MemoryEngine {
|
|
|
28
28
|
topicKey?: string | null;
|
|
29
29
|
metadata?: Record<string, unknown>;
|
|
30
30
|
}): Promise<Observation>;
|
|
31
|
-
deleteObservation(id: number): Promise<void>;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
deleteObservation(id: number, reason?: string): Promise<void>;
|
|
32
|
+
restoreObservation(id: number): Promise<Observation>;
|
|
33
|
+
purgeObservations(params: {
|
|
34
|
+
projectId?: string;
|
|
35
|
+
observationIds?: number[];
|
|
36
|
+
}): Promise<{
|
|
37
|
+
purgedCount: number;
|
|
38
|
+
purgedIds: number[];
|
|
39
|
+
}>;
|
|
40
|
+
listDeleted(params: {
|
|
36
41
|
projectId?: string;
|
|
37
|
-
topicKey?: string;
|
|
38
42
|
limit?: number;
|
|
39
|
-
offset?: number;
|
|
40
43
|
}): Promise<{
|
|
41
44
|
observations: Observation[];
|
|
42
45
|
total: number;
|
|
43
46
|
}>;
|
|
47
|
+
findMergeCandidates(params: {
|
|
48
|
+
projectId: string;
|
|
49
|
+
strategy: 'by_topic' | 'by_similarity';
|
|
50
|
+
similarityThreshold?: number;
|
|
51
|
+
}): Promise<MergeCandidates>;
|
|
52
|
+
mergeObservations(params: MergeParams): Promise<MergeResult[]>;
|
|
53
|
+
jaccardSimilarity(text1: string, text2: string): number;
|
|
54
|
+
exportObservations(params: ExportParams): Promise<ExportResult>;
|
|
55
|
+
private exportToJSON;
|
|
56
|
+
private exportToXML;
|
|
57
|
+
private exportToTXT;
|
|
58
|
+
getObservation(id: number): Promise<Observation | null>;
|
|
59
|
+
search(params: SearchParams): Promise<SearchResult>;
|
|
44
60
|
createSession(data: {
|
|
45
61
|
projectId: string;
|
|
46
62
|
endedAt: Date | null;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MemoryEngine.d.ts","sourceRoot":"","sources":["../src/MemoryEngine.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"MemoryEngine.d.ts","sourceRoot":"","sources":["../src/MemoryEngine.ts"],"names":[],"mappings":"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"}
|
package/dist/MemoryEngine.js
CHANGED
|
@@ -46,9 +46,7 @@ class MemoryEngine {
|
|
|
46
46
|
this.dbPath = dbPath;
|
|
47
47
|
try {
|
|
48
48
|
const dbDir = (0, path_1.dirname)(dbPath);
|
|
49
|
-
// Create directory structure if it doesn't exist
|
|
50
49
|
(0, fs_1.mkdirSync)(dbDir, { recursive: true });
|
|
51
|
-
// Create database connection
|
|
52
50
|
this.db = new bun_sqlite_1.Database(dbPath, { create: true });
|
|
53
51
|
this.initializeDatabase();
|
|
54
52
|
console.error(`✓ Database initialized successfully at: ${dbPath}`);
|
|
@@ -57,7 +55,6 @@ class MemoryEngine {
|
|
|
57
55
|
this.initError = error;
|
|
58
56
|
console.error(`✗ Failed to initialize database at ${dbPath}:`, error.message);
|
|
59
57
|
console.error(' The server will start but database operations will fail until this is resolved.');
|
|
60
|
-
// Create a mock database object to prevent null reference errors
|
|
61
58
|
this.db = this.createMockDatabase();
|
|
62
59
|
}
|
|
63
60
|
}
|
|
@@ -65,11 +62,7 @@ class MemoryEngine {
|
|
|
65
62
|
const throwError = () => {
|
|
66
63
|
throw new Error(`Database not initialized: ${this.initError?.message || 'Unknown error'}`);
|
|
67
64
|
};
|
|
68
|
-
return {
|
|
69
|
-
prepare: throwError,
|
|
70
|
-
exec: throwError,
|
|
71
|
-
close: () => { },
|
|
72
|
-
};
|
|
65
|
+
return { prepare: throwError, exec: throwError, close: () => { }, transaction: throwError };
|
|
73
66
|
}
|
|
74
67
|
isHealthy() {
|
|
75
68
|
return this.initError === null;
|
|
@@ -103,6 +96,7 @@ class MemoryEngine {
|
|
|
103
96
|
topic_key TEXT,
|
|
104
97
|
project_id TEXT NOT NULL,
|
|
105
98
|
created_at INTEGER NOT NULL,
|
|
99
|
+
deleted_at INTEGER DEFAULT NULL,
|
|
106
100
|
metadata TEXT,
|
|
107
101
|
FOREIGN KEY (session_id) REFERENCES sessions(id)
|
|
108
102
|
);
|
|
@@ -124,26 +118,72 @@ class MemoryEngine {
|
|
|
124
118
|
created_at INTEGER NOT NULL,
|
|
125
119
|
metadata TEXT
|
|
126
120
|
);
|
|
127
|
-
|
|
121
|
+
`);
|
|
122
|
+
// Migrate: add deleted_at column if missing
|
|
123
|
+
try {
|
|
124
|
+
this.db.exec('SELECT deleted_at FROM observations LIMIT 0');
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
try {
|
|
128
|
+
this.db.exec('ALTER TABLE observations ADD COLUMN deleted_at INTEGER DEFAULT NULL');
|
|
129
|
+
console.error('✓ Migration: added deleted_at column to observations');
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// Column may already exist in a concurrent scenario
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Create index for deleted_at if not exists
|
|
136
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_observations_deleted ON observations(deleted_at)');
|
|
137
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_observations_created ON observations(created_at)');
|
|
138
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_observations_type ON observations(type)');
|
|
139
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_observations_project ON observations(project_id)');
|
|
140
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_observations_topic ON observations(topic_key)');
|
|
141
|
+
// FTS5
|
|
142
|
+
this.db.exec(`
|
|
128
143
|
CREATE VIRTUAL TABLE IF NOT EXISTS observations_fts USING fts5(
|
|
129
144
|
title, content, topic_key, project_id,
|
|
130
|
-
content='observations'
|
|
145
|
+
content='observations',
|
|
146
|
+
tokenize='porter unicode61'
|
|
131
147
|
);
|
|
132
|
-
|
|
133
|
-
|
|
148
|
+
`);
|
|
149
|
+
// FTS triggers — drop and recreate to ensure latest version
|
|
150
|
+
this.db.exec('DROP TRIGGER IF EXISTS observations_ai');
|
|
151
|
+
this.db.exec('DROP TRIGGER IF EXISTS observations_ad');
|
|
152
|
+
this.db.exec('DROP TRIGGER IF EXISTS observations_au');
|
|
153
|
+
this.db.exec('DROP TRIGGER IF EXISTS observations_soft_delete');
|
|
154
|
+
this.db.exec('DROP TRIGGER IF EXISTS observations_undelete');
|
|
155
|
+
this.db.exec(`
|
|
156
|
+
CREATE TRIGGER observations_ai AFTER INSERT ON observations BEGIN
|
|
134
157
|
INSERT INTO observations_fts(rowid, title, content, topic_key, project_id)
|
|
135
158
|
VALUES (new.id, new.title, new.content, new.topic_key, new.project_id);
|
|
136
159
|
END;
|
|
137
160
|
|
|
138
|
-
CREATE TRIGGER
|
|
161
|
+
CREATE TRIGGER observations_ad AFTER DELETE ON observations BEGIN
|
|
139
162
|
DELETE FROM observations_fts WHERE rowid = old.id;
|
|
140
163
|
END;
|
|
141
164
|
|
|
142
|
-
CREATE TRIGGER
|
|
165
|
+
CREATE TRIGGER observations_au AFTER UPDATE ON observations
|
|
166
|
+
WHEN NEW.deleted_at IS NULL AND OLD.deleted_at IS NULL
|
|
167
|
+
BEGIN
|
|
143
168
|
DELETE FROM observations_fts WHERE rowid = old.id;
|
|
144
169
|
INSERT INTO observations_fts(rowid, title, content, topic_key, project_id)
|
|
145
170
|
VALUES (new.id, new.title, new.content, new.topic_key, new.project_id);
|
|
146
171
|
END;
|
|
172
|
+
|
|
173
|
+
CREATE TRIGGER observations_soft_delete
|
|
174
|
+
AFTER UPDATE OF deleted_at ON observations
|
|
175
|
+
WHEN NEW.deleted_at IS NOT NULL AND OLD.deleted_at IS NULL
|
|
176
|
+
BEGIN
|
|
177
|
+
DELETE FROM observations_fts WHERE rowid = OLD.id;
|
|
178
|
+
END;
|
|
179
|
+
|
|
180
|
+
CREATE TRIGGER observations_undelete
|
|
181
|
+
AFTER UPDATE OF deleted_at ON observations
|
|
182
|
+
WHEN NEW.deleted_at IS NULL AND OLD.deleted_at IS NOT NULL
|
|
183
|
+
BEGIN
|
|
184
|
+
INSERT INTO observations_fts(rowid, title, content, topic_key, project_id)
|
|
185
|
+
VALUES (NEW.id, NEW.title, NEW.content, NEW.topic_key, NEW.project_id);
|
|
186
|
+
END;
|
|
147
187
|
`);
|
|
148
188
|
}
|
|
149
189
|
serialize(value) {
|
|
@@ -164,18 +204,19 @@ class MemoryEngine {
|
|
|
164
204
|
throw this.initError || new Error('Database not initialized');
|
|
165
205
|
}
|
|
166
206
|
}
|
|
207
|
+
// ─── Observations CRUD ─────────────────────────────────────
|
|
167
208
|
async createObservation(data) {
|
|
168
209
|
this.checkHealth();
|
|
169
210
|
const uuid = crypto.randomUUID();
|
|
170
211
|
const createdAt = new Date();
|
|
171
212
|
const metadata = this.serialize(data.metadata);
|
|
172
213
|
const result = this.db
|
|
173
|
-
.prepare(`INSERT INTO observations (uuid, session_id, title, content, type, topic_key, project_id, created_at, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
214
|
+
.prepare(`INSERT INTO observations (uuid, session_id, title, content, type, topic_key, project_id, created_at, deleted_at, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)`)
|
|
174
215
|
.run(uuid, data.sessionId, data.title, data.content, data.type, data.topicKey ?? null, data.projectId, createdAt.getTime(), metadata);
|
|
175
216
|
const id = typeof result.lastInsertRowid === 'bigint'
|
|
176
217
|
? Number(result.lastInsertRowid)
|
|
177
218
|
: result.lastInsertRowid;
|
|
178
|
-
const observation = await this.getObservationById(id);
|
|
219
|
+
const observation = await this.getObservationById(id, true);
|
|
179
220
|
if (!observation)
|
|
180
221
|
throw new Error('Failed to retrieve created observation');
|
|
181
222
|
return observation;
|
|
@@ -184,6 +225,8 @@ class MemoryEngine {
|
|
|
184
225
|
const current = await this.getObservationById(id);
|
|
185
226
|
if (!current)
|
|
186
227
|
throw new Error('Observation not found');
|
|
228
|
+
if (current.deletedAt)
|
|
229
|
+
throw new Error('Cannot update a soft-deleted observation');
|
|
187
230
|
const fields = [];
|
|
188
231
|
const values = [];
|
|
189
232
|
if (updates.title !== undefined) {
|
|
@@ -210,25 +253,388 @@ class MemoryEngine {
|
|
|
210
253
|
return current;
|
|
211
254
|
values.push(id);
|
|
212
255
|
this.db.prepare(`UPDATE observations SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
|
213
|
-
const updated = await this.getObservationById(id);
|
|
256
|
+
const updated = await this.getObservationById(id, true);
|
|
214
257
|
if (!updated)
|
|
215
258
|
throw new Error('Failed to update observation');
|
|
216
259
|
return updated;
|
|
217
260
|
}
|
|
218
|
-
|
|
219
|
-
|
|
261
|
+
// ─── Soft Delete / Restore / Purge ─────────────────────────
|
|
262
|
+
async deleteObservation(id, reason) {
|
|
263
|
+
this.checkHealth();
|
|
264
|
+
const obs = await this.getObservationById(id, true);
|
|
265
|
+
if (!obs)
|
|
266
|
+
throw new Error('Observation not found');
|
|
267
|
+
if (obs.deletedAt)
|
|
268
|
+
throw new Error('Observation already deleted');
|
|
269
|
+
const now = Date.now();
|
|
270
|
+
// If there's a reason, store it in metadata
|
|
271
|
+
if (reason) {
|
|
272
|
+
const meta = { ...obs.metadata, deleteReason: reason };
|
|
273
|
+
this.db
|
|
274
|
+
.prepare('UPDATE observations SET deleted_at = ?, metadata = ? WHERE id = ?')
|
|
275
|
+
.run(now, this.serialize(meta), id);
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
this.db.prepare('UPDATE observations SET deleted_at = ? WHERE id = ?').run(now, id);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
async restoreObservation(id) {
|
|
282
|
+
this.checkHealth();
|
|
283
|
+
const obs = await this.getObservationById(id, true);
|
|
284
|
+
if (!obs)
|
|
285
|
+
throw new Error('Observation not found');
|
|
286
|
+
if (!obs.deletedAt)
|
|
287
|
+
throw new Error('Observation is not deleted');
|
|
288
|
+
this.db.prepare('UPDATE observations SET deleted_at = NULL WHERE id = ?').run(id);
|
|
289
|
+
const restored = await this.getObservationById(id, true);
|
|
290
|
+
if (!restored)
|
|
291
|
+
throw new Error('Failed to restore observation');
|
|
292
|
+
return restored;
|
|
293
|
+
}
|
|
294
|
+
async purgeObservations(params) {
|
|
295
|
+
this.checkHealth();
|
|
296
|
+
let sql = 'SELECT id FROM observations WHERE deleted_at IS NOT NULL';
|
|
297
|
+
const values = [];
|
|
298
|
+
if (params.observationIds && params.observationIds.length > 0) {
|
|
299
|
+
const placeholders = params.observationIds.map(() => '?').join(',');
|
|
300
|
+
sql += ` AND id IN (${placeholders})`;
|
|
301
|
+
values.push(...params.observationIds);
|
|
302
|
+
}
|
|
303
|
+
if (params.projectId) {
|
|
304
|
+
sql += ' AND project_id = ?';
|
|
305
|
+
values.push(params.projectId);
|
|
306
|
+
}
|
|
307
|
+
const rows = this.db.prepare(sql).all(...values);
|
|
308
|
+
const ids = rows.map((r) => r.id);
|
|
309
|
+
if (ids.length === 0)
|
|
310
|
+
return { purgedCount: 0, purgedIds: [] };
|
|
311
|
+
const placeholders = ids.map(() => '?').join(',');
|
|
312
|
+
this.db.prepare(`DELETE FROM observations WHERE id IN (${placeholders})`).run(...ids);
|
|
313
|
+
return { purgedCount: ids.length, purgedIds: ids };
|
|
314
|
+
}
|
|
315
|
+
async listDeleted(params) {
|
|
316
|
+
this.checkHealth();
|
|
317
|
+
const { projectId, limit = 20 } = params;
|
|
318
|
+
let countSql = 'SELECT COUNT(*) as count FROM observations WHERE deleted_at IS NOT NULL';
|
|
319
|
+
let sql = 'SELECT * FROM observations WHERE deleted_at IS NOT NULL';
|
|
320
|
+
const values = [];
|
|
321
|
+
if (projectId) {
|
|
322
|
+
countSql += ' AND project_id = ?';
|
|
323
|
+
sql += ' AND project_id = ?';
|
|
324
|
+
values.push(projectId);
|
|
325
|
+
}
|
|
326
|
+
const countResult = this.db.prepare(countSql).get(...values);
|
|
327
|
+
const total = countResult ? countResult.count : 0;
|
|
328
|
+
sql += ' ORDER BY deleted_at DESC LIMIT ?';
|
|
329
|
+
const rows = this.db.prepare(sql).all(...values, limit);
|
|
330
|
+
const observations = rows.map((row) => this.mapObservation(row));
|
|
331
|
+
return { observations, total };
|
|
332
|
+
}
|
|
333
|
+
// ─── Merge ─────────────────────────────────────────────────
|
|
334
|
+
async findMergeCandidates(params) {
|
|
335
|
+
this.checkHealth();
|
|
336
|
+
const { projectId, strategy, similarityThreshold = 0.85 } = params;
|
|
337
|
+
const groups = [];
|
|
338
|
+
if (strategy === 'by_topic') {
|
|
339
|
+
const rows = this.db
|
|
340
|
+
.prepare(`SELECT topic_key, COUNT(*) as cnt FROM observations
|
|
341
|
+
WHERE project_id = ? AND deleted_at IS NULL AND topic_key IS NOT NULL AND topic_key != ''
|
|
342
|
+
GROUP BY topic_key HAVING cnt >= 2
|
|
343
|
+
ORDER BY cnt DESC`)
|
|
344
|
+
.all(projectId);
|
|
345
|
+
for (const row of rows) {
|
|
346
|
+
const obs = this.db
|
|
347
|
+
.prepare('SELECT * FROM observations WHERE project_id = ? AND topic_key = ? AND deleted_at IS NULL ORDER BY created_at ASC')
|
|
348
|
+
.all(projectId, row.topic_key);
|
|
349
|
+
groups.push({
|
|
350
|
+
reason: `topic_key: ${row.topic_key}`,
|
|
351
|
+
observations: obs.map((o) => this.mapObservation(o)),
|
|
352
|
+
estimatedReduction: obs.length - 1,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
// by_similarity — compare recent observations pairwise
|
|
358
|
+
const allObs = this.db
|
|
359
|
+
.prepare('SELECT * FROM observations WHERE project_id = ? AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 200')
|
|
360
|
+
.all(projectId);
|
|
361
|
+
const mapped = allObs.map((o) => this.mapObservation(o));
|
|
362
|
+
const visited = new Set();
|
|
363
|
+
for (let i = 0; i < mapped.length; i++) {
|
|
364
|
+
if (visited.has(mapped[i].id))
|
|
365
|
+
continue;
|
|
366
|
+
const group = [mapped[i]];
|
|
367
|
+
for (let j = i + 1; j < mapped.length; j++) {
|
|
368
|
+
if (visited.has(mapped[j].id))
|
|
369
|
+
continue;
|
|
370
|
+
const sim = this.jaccardSimilarity(mapped[i].content, mapped[j].content);
|
|
371
|
+
if (sim >= similarityThreshold) {
|
|
372
|
+
group.push(mapped[j]);
|
|
373
|
+
visited.add(mapped[j].id);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (group.length >= 2) {
|
|
377
|
+
visited.add(mapped[i].id);
|
|
378
|
+
groups.push({
|
|
379
|
+
reason: `similarity >= ${similarityThreshold}`,
|
|
380
|
+
observations: group,
|
|
381
|
+
estimatedReduction: group.length - 1,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const totalCandidates = groups.reduce((acc, g) => acc + g.observations.length, 0);
|
|
387
|
+
const estimatedReduction = groups.reduce((acc, g) => acc + g.estimatedReduction, 0);
|
|
388
|
+
return { groups, totalCandidates, estimatedReduction };
|
|
389
|
+
}
|
|
390
|
+
async mergeObservations(params) {
|
|
391
|
+
this.checkHealth();
|
|
392
|
+
const { projectId, topicKey, observationIds, strategy, dryRun = false } = params;
|
|
393
|
+
let groups = [];
|
|
394
|
+
if (strategy === 'by_ids' && observationIds && observationIds.length >= 2) {
|
|
395
|
+
const obs = [];
|
|
396
|
+
for (const id of observationIds) {
|
|
397
|
+
const o = await this.getObservationById(id);
|
|
398
|
+
if (!o)
|
|
399
|
+
throw new Error(`Observation ${id} not found`);
|
|
400
|
+
if (o.deletedAt)
|
|
401
|
+
throw new Error(`Observation ${id} is soft-deleted`);
|
|
402
|
+
if (o.projectId !== projectId) {
|
|
403
|
+
throw new Error(`Observation ${id} belongs to project '${o.projectId}', not '${projectId}'`);
|
|
404
|
+
}
|
|
405
|
+
obs.push(o);
|
|
406
|
+
}
|
|
407
|
+
groups = [{ observations: obs }];
|
|
408
|
+
}
|
|
409
|
+
else if (strategy === 'by_topic') {
|
|
410
|
+
const candidates = await this.findMergeCandidates({
|
|
411
|
+
projectId,
|
|
412
|
+
strategy: 'by_topic',
|
|
413
|
+
});
|
|
414
|
+
if (topicKey) {
|
|
415
|
+
groups = candidates.groups.filter((g) => g.reason === `topic_key: ${topicKey}`);
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
groups = candidates.groups;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
const candidates = await this.findMergeCandidates({
|
|
423
|
+
projectId,
|
|
424
|
+
strategy: 'by_similarity',
|
|
425
|
+
});
|
|
426
|
+
groups = candidates.groups;
|
|
427
|
+
}
|
|
428
|
+
if (groups.length === 0)
|
|
429
|
+
return [];
|
|
430
|
+
if (dryRun) {
|
|
431
|
+
return groups.map((g) => ({
|
|
432
|
+
mergedObservation: g.observations[0], // placeholder
|
|
433
|
+
deletedIds: g.observations.map((o) => o.id),
|
|
434
|
+
originalCount: g.observations.length,
|
|
435
|
+
strategy,
|
|
436
|
+
}));
|
|
437
|
+
}
|
|
438
|
+
const results = [];
|
|
439
|
+
const doMerge = this.db.transaction(() => {
|
|
440
|
+
for (const group of groups) {
|
|
441
|
+
const obs = group.observations.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
|
442
|
+
// Synthesize content
|
|
443
|
+
const synthTitle = obs[obs.length - 1].title; // most recent title
|
|
444
|
+
const synthContent = obs
|
|
445
|
+
.map((o) => `--- [${o.createdAt.toISOString()}] ${o.title} ---\n${o.content}`)
|
|
446
|
+
.join('\n\n');
|
|
447
|
+
// Most frequent type
|
|
448
|
+
const typeCounts = {};
|
|
449
|
+
for (const o of obs) {
|
|
450
|
+
typeCounts[o.type] = (typeCounts[o.type] || 0) + 1;
|
|
451
|
+
}
|
|
452
|
+
const synthType = Object.entries(typeCounts).sort((a, b) => b[1] - a[1])[0][0];
|
|
453
|
+
const synthTopicKey = obs.find((o) => o.topicKey)?.topicKey ?? null;
|
|
454
|
+
const sourceIds = obs.map((o) => o.id);
|
|
455
|
+
const synthMetadata = this.serialize({
|
|
456
|
+
merged: true,
|
|
457
|
+
sourceIds,
|
|
458
|
+
mergedAt: new Date().toISOString(),
|
|
459
|
+
originalCount: obs.length,
|
|
460
|
+
});
|
|
461
|
+
const uuid = crypto.randomUUID();
|
|
462
|
+
const createdAt = Date.now();
|
|
463
|
+
const sessionId = obs[obs.length - 1].sessionId;
|
|
464
|
+
const insertResult = this.db
|
|
465
|
+
.prepare(`INSERT INTO observations (uuid, session_id, title, content, type, topic_key, project_id, created_at, deleted_at, metadata)
|
|
466
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)`)
|
|
467
|
+
.run(uuid, sessionId, synthTitle, synthContent, synthType, synthTopicKey, projectId, createdAt, synthMetadata);
|
|
468
|
+
const newId = typeof insertResult.lastInsertRowid === 'bigint'
|
|
469
|
+
? Number(insertResult.lastInsertRowid)
|
|
470
|
+
: insertResult.lastInsertRowid;
|
|
471
|
+
// Delete originals
|
|
472
|
+
const placeholders = sourceIds.map(() => '?').join(',');
|
|
473
|
+
this.db.prepare(`DELETE FROM observations WHERE id IN (${placeholders})`).run(...sourceIds);
|
|
474
|
+
// Fetch the newly created merged observation
|
|
475
|
+
const merged = this.db.prepare('SELECT * FROM observations WHERE id = ?').get(newId);
|
|
476
|
+
results.push({
|
|
477
|
+
mergedObservation: this.mapObservation(merged),
|
|
478
|
+
deletedIds: sourceIds,
|
|
479
|
+
originalCount: obs.length,
|
|
480
|
+
strategy,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
doMerge();
|
|
485
|
+
return results;
|
|
486
|
+
}
|
|
487
|
+
jaccardSimilarity(text1, text2) {
|
|
488
|
+
const tokenize = (t) => new Set(t
|
|
489
|
+
.toLowerCase()
|
|
490
|
+
.split(/\s+/)
|
|
491
|
+
.filter((w) => w.length > 2));
|
|
492
|
+
const set1 = tokenize(text1);
|
|
493
|
+
const set2 = tokenize(text2);
|
|
494
|
+
const intersection = [...set1].filter((x) => set2.has(x)).length;
|
|
495
|
+
const union = new Set([...set1, ...set2]).size;
|
|
496
|
+
return union === 0 ? 0 : intersection / union;
|
|
220
497
|
}
|
|
498
|
+
// ─── Export ────────────────────────────────────────────────
|
|
499
|
+
async exportObservations(params) {
|
|
500
|
+
this.checkHealth();
|
|
501
|
+
const { format, projectId, type, topicKey, dateFrom, dateTo, includeDeleted = false } = params;
|
|
502
|
+
let sql = 'SELECT * FROM observations WHERE 1=1';
|
|
503
|
+
const values = [];
|
|
504
|
+
if (!includeDeleted) {
|
|
505
|
+
sql += ' AND deleted_at IS NULL';
|
|
506
|
+
}
|
|
507
|
+
if (projectId) {
|
|
508
|
+
sql += ' AND project_id = ?';
|
|
509
|
+
values.push(projectId);
|
|
510
|
+
}
|
|
511
|
+
if (type) {
|
|
512
|
+
sql += ' AND type = ?';
|
|
513
|
+
values.push(type);
|
|
514
|
+
}
|
|
515
|
+
if (topicKey) {
|
|
516
|
+
sql += ' AND topic_key = ?';
|
|
517
|
+
values.push(topicKey);
|
|
518
|
+
}
|
|
519
|
+
if (dateFrom) {
|
|
520
|
+
sql += ' AND created_at >= ?';
|
|
521
|
+
values.push(dateFrom.getTime());
|
|
522
|
+
}
|
|
523
|
+
if (dateTo) {
|
|
524
|
+
sql += ' AND created_at <= ?';
|
|
525
|
+
values.push(dateTo.getTime());
|
|
526
|
+
}
|
|
527
|
+
sql += ' ORDER BY created_at ASC';
|
|
528
|
+
const rows = this.db.prepare(sql).all(...values);
|
|
529
|
+
const observations = rows.map((row) => this.mapObservation(row));
|
|
530
|
+
const exportedAt = new Date();
|
|
531
|
+
let content;
|
|
532
|
+
switch (format) {
|
|
533
|
+
case 'json':
|
|
534
|
+
content = this.exportToJSON(observations, exportedAt, params);
|
|
535
|
+
break;
|
|
536
|
+
case 'xml':
|
|
537
|
+
content = this.exportToXML(observations, exportedAt, params);
|
|
538
|
+
break;
|
|
539
|
+
case 'txt':
|
|
540
|
+
content = this.exportToTXT(observations, exportedAt, params);
|
|
541
|
+
break;
|
|
542
|
+
default:
|
|
543
|
+
throw new Error(`Unsupported export format: ${format}`);
|
|
544
|
+
}
|
|
545
|
+
return {
|
|
546
|
+
content,
|
|
547
|
+
format,
|
|
548
|
+
recordCount: observations.length,
|
|
549
|
+
exportedAt,
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
exportToJSON(observations, exportedAt, params) {
|
|
553
|
+
const data = {
|
|
554
|
+
exportedAt: exportedAt.toISOString(),
|
|
555
|
+
version: '1.0',
|
|
556
|
+
project: params.projectId || 'all',
|
|
557
|
+
filters: {
|
|
558
|
+
...(params.type && { type: params.type }),
|
|
559
|
+
...(params.topicKey && { topicKey: params.topicKey }),
|
|
560
|
+
...(params.dateFrom && { dateFrom: params.dateFrom.toISOString() }),
|
|
561
|
+
...(params.dateTo && { dateTo: params.dateTo.toISOString() }),
|
|
562
|
+
...(params.includeDeleted && { includeDeleted: true }),
|
|
563
|
+
},
|
|
564
|
+
totalRecords: observations.length,
|
|
565
|
+
observations: observations.map((o) => ({
|
|
566
|
+
id: o.id,
|
|
567
|
+
uuid: o.uuid,
|
|
568
|
+
title: o.title,
|
|
569
|
+
content: o.content,
|
|
570
|
+
type: o.type,
|
|
571
|
+
topicKey: o.topicKey,
|
|
572
|
+
projectId: o.projectId,
|
|
573
|
+
createdAt: o.createdAt.toISOString(),
|
|
574
|
+
...(o.deletedAt && { deletedAt: o.deletedAt.toISOString() }),
|
|
575
|
+
metadata: o.metadata,
|
|
576
|
+
})),
|
|
577
|
+
};
|
|
578
|
+
return JSON.stringify(data, null, 2);
|
|
579
|
+
}
|
|
580
|
+
exportToXML(observations, exportedAt, params) {
|
|
581
|
+
const escapeXml = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
582
|
+
let xml = `<?xml version="1.0" encoding="UTF-8"?>\n`;
|
|
583
|
+
xml += `<memento-export version="1.0" exportedAt="${exportedAt.toISOString()}">\n`;
|
|
584
|
+
xml += ` <project>${escapeXml(params.projectId || 'all')}</project>\n`;
|
|
585
|
+
xml += ` <observations count="${observations.length}">\n`;
|
|
586
|
+
for (const o of observations) {
|
|
587
|
+
xml += ` <observation id="${o.id}" uuid="${escapeXml(o.uuid)}">\n`;
|
|
588
|
+
xml += ` <title>${escapeXml(o.title)}</title>\n`;
|
|
589
|
+
xml += ` <content><![CDATA[${o.content}]]></content>\n`;
|
|
590
|
+
xml += ` <type>${escapeXml(o.type)}</type>\n`;
|
|
591
|
+
if (o.topicKey)
|
|
592
|
+
xml += ` <topicKey>${escapeXml(o.topicKey)}</topicKey>\n`;
|
|
593
|
+
xml += ` <projectId>${escapeXml(o.projectId)}</projectId>\n`;
|
|
594
|
+
xml += ` <createdAt>${o.createdAt.toISOString()}</createdAt>\n`;
|
|
595
|
+
if (o.deletedAt)
|
|
596
|
+
xml += ` <deletedAt>${o.deletedAt.toISOString()}</deletedAt>\n`;
|
|
597
|
+
xml += ` </observation>\n`;
|
|
598
|
+
}
|
|
599
|
+
xml += ` </observations>\n`;
|
|
600
|
+
xml += `</memento-export>\n`;
|
|
601
|
+
return xml;
|
|
602
|
+
}
|
|
603
|
+
exportToTXT(observations, exportedAt, params) {
|
|
604
|
+
const sep = '═'.repeat(50);
|
|
605
|
+
const thinSep = '─'.repeat(50);
|
|
606
|
+
const dateStr = exportedAt.toISOString().split('T')[0];
|
|
607
|
+
let txt = `${sep}\n`;
|
|
608
|
+
txt += `MEMENTO EXPORT — Project: ${params.projectId || 'all'}\n`;
|
|
609
|
+
txt += `Exported: ${dateStr} | Records: ${observations.length}\n`;
|
|
610
|
+
txt += `${sep}\n\n`;
|
|
611
|
+
for (const o of observations) {
|
|
612
|
+
txt += `[#${o.id}] ${o.title}\n`;
|
|
613
|
+
txt += `Type: ${o.type}`;
|
|
614
|
+
if (o.topicKey)
|
|
615
|
+
txt += ` | Topic: ${o.topicKey}`;
|
|
616
|
+
txt += `\nDate: ${o.createdAt.toISOString().split('T')[0]}`;
|
|
617
|
+
if (o.deletedAt)
|
|
618
|
+
txt += ` | DELETED: ${o.deletedAt.toISOString().split('T')[0]}`;
|
|
619
|
+
txt += `\n\n ${o.content}\n\n${thinSep}\n\n`;
|
|
620
|
+
}
|
|
621
|
+
return txt;
|
|
622
|
+
}
|
|
623
|
+
// ─── Search ────────────────────────────────────────────────
|
|
221
624
|
async getObservation(id) {
|
|
222
625
|
return await this.getObservationById(id);
|
|
223
626
|
}
|
|
224
627
|
async search(params) {
|
|
225
|
-
const { query, type, projectId, topicKey, limit = 100, offset = 0 } = params;
|
|
226
|
-
let sql
|
|
628
|
+
const { query, type, projectId, topicKey, limit = 100, offset = 0, includeDeleted = false, } = params;
|
|
629
|
+
let sql;
|
|
227
630
|
const values = [];
|
|
228
631
|
if (query) {
|
|
229
632
|
sql =
|
|
230
633
|
'SELECT observations.* FROM observations JOIN observations_fts ON observations.id = observations_fts.rowid WHERE observations_fts MATCH ?';
|
|
231
634
|
values.push(query);
|
|
635
|
+
if (!includeDeleted) {
|
|
636
|
+
sql += ' AND observations.deleted_at IS NULL';
|
|
637
|
+
}
|
|
232
638
|
if (type) {
|
|
233
639
|
sql += ' AND observations.type = ?';
|
|
234
640
|
values.push(type);
|
|
@@ -243,6 +649,10 @@ class MemoryEngine {
|
|
|
243
649
|
}
|
|
244
650
|
}
|
|
245
651
|
else {
|
|
652
|
+
sql = 'SELECT * FROM observations WHERE 1=1';
|
|
653
|
+
if (!includeDeleted) {
|
|
654
|
+
sql += ' AND deleted_at IS NULL';
|
|
655
|
+
}
|
|
246
656
|
if (type) {
|
|
247
657
|
sql += ' AND type = ?';
|
|
248
658
|
values.push(type);
|
|
@@ -265,6 +675,7 @@ class MemoryEngine {
|
|
|
265
675
|
const observations = rows.map((row) => this.mapObservation(row));
|
|
266
676
|
return { observations, total };
|
|
267
677
|
}
|
|
678
|
+
// ─── Sessions ──────────────────────────────────────────────
|
|
268
679
|
async createSession(data) {
|
|
269
680
|
const uuid = crypto.randomUUID();
|
|
270
681
|
const startedAt = new Date();
|
|
@@ -294,6 +705,7 @@ class MemoryEngine {
|
|
|
294
705
|
async getSession(id) {
|
|
295
706
|
return await this.getSessionById(id);
|
|
296
707
|
}
|
|
708
|
+
// ─── Prompts ───────────────────────────────────────────────
|
|
297
709
|
async savePrompt(data) {
|
|
298
710
|
const uuid = crypto.randomUUID();
|
|
299
711
|
const createdAt = new Date();
|
|
@@ -309,8 +721,13 @@ class MemoryEngine {
|
|
|
309
721
|
throw new Error('Failed to retrieve saved prompt');
|
|
310
722
|
return prompt;
|
|
311
723
|
}
|
|
312
|
-
|
|
313
|
-
|
|
724
|
+
// ─── Private helpers ───────────────────────────────────────
|
|
725
|
+
async getObservationById(id, includeDeleted = false) {
|
|
726
|
+
let sql = 'SELECT * FROM observations WHERE id = ?';
|
|
727
|
+
if (!includeDeleted) {
|
|
728
|
+
sql += ' AND deleted_at IS NULL';
|
|
729
|
+
}
|
|
730
|
+
const row = this.db.prepare(sql).get(id);
|
|
314
731
|
if (!row)
|
|
315
732
|
return null;
|
|
316
733
|
return this.mapObservation(row);
|
|
@@ -339,6 +756,7 @@ class MemoryEngine {
|
|
|
339
756
|
topicKey: r.topic_key,
|
|
340
757
|
projectId: r.project_id,
|
|
341
758
|
createdAt: new Date(r.created_at),
|
|
759
|
+
deletedAt: r.deleted_at ? new Date(r.deleted_at) : null,
|
|
342
760
|
metadata: this.deserialize(r.metadata),
|
|
343
761
|
};
|
|
344
762
|
}
|