@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.
@@ -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
- getObservation(id: number): Promise<Observation | null>;
33
- search(params: {
34
- query?: string;
35
- type?: Observation['type'];
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,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAM/D,qBAAa,YAAY;IACvB,OAAO,CAAC,EAAE,CAAM;IAChB,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,SAAS,CAAsB;gBAE3B,MAAM,GAAE,MAA4B;IA0BhD,OAAO,CAAC,kBAAkB;IAY1B,SAAS,IAAI,OAAO;IAIpB,YAAY,IAAI,KAAK,GAAG,IAAI;IAI5B,eAAe,IAAI,MAAM;IAIzB,OAAO,CAAC,kBAAkB;IAoE1B,OAAO,CAAC,SAAS;IAIjB,OAAO,CAAC,WAAW;IASnB,OAAO,CAAC,WAAW;IAMb,iBAAiB,CAAC,IAAI,EAAE;QAC5B,SAAS,EAAE,MAAM,CAAC;QAClB,KAAK,EAAE,MAAM,CAAC;QACd,OAAO,EAAE,MAAM,CAAC;QAChB,IAAI,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;QAC1B,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;QACxB,SAAS,EAAE,MAAM,CAAC;QAClB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;KACnC,GAAG,OAAO,CAAC,WAAW,CAAC;IA+BlB,iBAAiB,CACrB,EAAE,EAAE,MAAM,EACV,OAAO,EAAE;QACP,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,IAAI,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;QAC3B,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QACzB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;KACpC,GACA,OAAO,CAAC,WAAW,CAAC;IAsCjB,iBAAiB,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAI5C,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC;IAIvD,MAAM,CAAC,MAAM,EAAE;QACnB,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,IAAI,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;QAC3B,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,MAAM,CAAC,EAAE,MAAM,CAAC;KACjB,GAAG,OAAO,CAAC;QAAE,YAAY,EAAE,WAAW,EAAE,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;IAgDrD,aAAa,CAAC,IAAI,EAAE;QACxB,SAAS,EAAE,MAAM,CAAC;QAClB,OAAO,EAAE,IAAI,GAAG,IAAI,CAAC;QACrB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;KACnC,GAAG,OAAO,CAAC,OAAO,CAAC;IAoBd,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAUxC,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC;IAI/C,UAAU,CAAC,IAAI,EAAE;QACrB,SAAS,EAAE,MAAM,CAAC;QAClB,OAAO,EAAE,MAAM,CAAC;QAChB,SAAS,EAAE,MAAM,CAAC;QAClB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;KACnC,GAAG,OAAO,CAAC,MAAM,CAAC;YAoBL,kBAAkB;YAMlB,cAAc;YAMd,aAAa;IAM3B,OAAO,CAAC,cAAc;IA2BtB,OAAO,CAAC,UAAU;IAmBlB,OAAO,CAAC,SAAS;IAqBjB,KAAK,IAAI,IAAI;CAGd"}
1
+ {"version":3,"file":"MemoryEngine.d.ts","sourceRoot":"","sources":["../src/MemoryEngine.ts"],"names":[],"mappings":"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"}
@@ -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
- CREATE TRIGGER IF NOT EXISTS observations_ai AFTER INSERT ON observations BEGIN
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 IF NOT EXISTS observations_ad AFTER DELETE ON observations BEGIN
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 IF NOT EXISTS observations_au AFTER UPDATE ON observations BEGIN
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
- async deleteObservation(id) {
219
- this.db.prepare('DELETE FROM observations WHERE id = ?').run(id);
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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 = 'SELECT * FROM observations WHERE 1=1';
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
- async getObservationById(id) {
313
- const row = this.db.prepare('SELECT * FROM observations WHERE id = ?').get(id);
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
  }