engram-mcp-server 1.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 (44) hide show
  1. package/README.md +645 -0
  2. package/dist/constants.d.ts +21 -0
  3. package/dist/constants.js +81 -0
  4. package/dist/database.d.ts +30 -0
  5. package/dist/database.js +134 -0
  6. package/dist/index.d.ts +3 -0
  7. package/dist/index.js +67 -0
  8. package/dist/migrations.d.ts +4 -0
  9. package/dist/migrations.js +342 -0
  10. package/dist/scripts/install-hooks.d.ts +3 -0
  11. package/dist/scripts/install-hooks.js +89 -0
  12. package/dist/tools/intelligence.d.ts +3 -0
  13. package/dist/tools/intelligence.js +427 -0
  14. package/dist/tools/maintenance.d.ts +3 -0
  15. package/dist/tools/maintenance.js +646 -0
  16. package/dist/tools/memory.d.ts +3 -0
  17. package/dist/tools/memory.js +446 -0
  18. package/dist/tools/scheduler.d.ts +3 -0
  19. package/dist/tools/scheduler.js +363 -0
  20. package/dist/tools/sessions.d.ts +3 -0
  21. package/dist/tools/sessions.js +355 -0
  22. package/dist/tools/tasks.d.ts +3 -0
  23. package/dist/tools/tasks.js +206 -0
  24. package/dist/types.d.ts +170 -0
  25. package/dist/types.js +5 -0
  26. package/dist/utils.d.ts +58 -0
  27. package/dist/utils.js +190 -0
  28. package/docs/scheduled-events.md +150 -0
  29. package/package.json +43 -0
  30. package/scripts/install-mcp.js +175 -0
  31. package/src/constants.ts +86 -0
  32. package/src/database.ts +162 -0
  33. package/src/index.ts +79 -0
  34. package/src/migrations.ts +367 -0
  35. package/src/scripts/install-hooks.ts +96 -0
  36. package/src/tools/intelligence.ts +469 -0
  37. package/src/tools/maintenance.ts +783 -0
  38. package/src/tools/memory.ts +543 -0
  39. package/src/tools/scheduler.ts +413 -0
  40. package/src/tools/sessions.ts +430 -0
  41. package/src/tools/tasks.ts +215 -0
  42. package/src/types.ts +267 -0
  43. package/src/utils.ts +216 -0
  44. package/tsconfig.json +19 -0
@@ -0,0 +1,783 @@
1
+ // ============================================================================
2
+ // Engram MCP Server — Maintenance, Backup & Milestone Tools
3
+ // ============================================================================
4
+
5
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
+ import { z } from "zod";
7
+ import * as fs from "fs";
8
+ import * as path from "path";
9
+ import { getDb, now, getCurrentSessionId, getProjectRoot, getDbSizeKb, getDbPath, backupDatabase } from "../database.js";
10
+ import { TOOL_PREFIX, DB_DIR_NAME, DB_FILE_NAME, BACKUP_DIR_NAME, COMPACTION_THRESHOLD_SESSIONS, MAX_BACKUP_COUNT, SERVER_VERSION } from "../constants.js";
11
+ import type { MemoryStats, CompactionResult, BackupInfo } from "../types.js";
12
+
13
+ export function registerMaintenanceTools(server: McpServer): void {
14
+ // ─── MEMORY STATS ───────────────────────────────────────────────────
15
+ server.registerTool(
16
+ `${TOOL_PREFIX}_stats`,
17
+ {
18
+ title: "Memory Statistics",
19
+ description: `Get a comprehensive overview of everything stored in Engram's memory: session count, changes, decisions, file notes, conventions, tasks, milestones, most-changed files, and database size.
20
+
21
+ Returns:
22
+ MemoryStats object with counts and insights.`,
23
+ inputSchema: {},
24
+ annotations: {
25
+ readOnlyHint: true,
26
+ destructiveHint: false,
27
+ idempotentHint: true,
28
+ openWorldHint: false,
29
+ },
30
+ },
31
+ async () => {
32
+ const db = getDb();
33
+
34
+ const count = (table: string): number =>
35
+ (db.prepare(`SELECT COUNT(*) as c FROM ${table}`).get() as { c: number }).c;
36
+
37
+ const oldest = db.prepare("SELECT started_at FROM sessions ORDER BY id ASC LIMIT 1").get() as { started_at: string } | undefined;
38
+ const newest = db.prepare("SELECT started_at FROM sessions ORDER BY id DESC LIMIT 1").get() as { started_at: string } | undefined;
39
+
40
+ const mostChanged = db.prepare(`
41
+ SELECT file_path, COUNT(*) as change_count
42
+ FROM changes GROUP BY file_path ORDER BY change_count DESC LIMIT 10
43
+ `).all() as Array<{ file_path: string; change_count: number }>;
44
+
45
+ // Layer distribution from file notes
46
+ const layerDist = db.prepare(`
47
+ SELECT layer, COUNT(*) as count FROM file_notes WHERE layer IS NOT NULL GROUP BY layer ORDER BY count DESC
48
+ `).all() as Array<{ layer: string; count: number }>;
49
+
50
+ // Task stats
51
+ const tasksByStatus = db.prepare(`
52
+ SELECT status, COUNT(*) as count FROM tasks GROUP BY status ORDER BY count DESC
53
+ `).all() as Array<{ status: string; count: number }>;
54
+
55
+ // Schema version
56
+ let schemaVersion = 0;
57
+ try {
58
+ const vRow = db.prepare("SELECT value FROM schema_meta WHERE key = 'version'").get() as { value: string } | undefined;
59
+ schemaVersion = vRow ? parseInt(vRow.value, 10) : 0;
60
+ } catch { /* no schema_meta */ }
61
+
62
+ const stats: MemoryStats & {
63
+ layer_distribution: typeof layerDist;
64
+ tasks_by_status: typeof tasksByStatus;
65
+ schema_version: number;
66
+ engine: string;
67
+ } = {
68
+ total_sessions: count("sessions"),
69
+ total_changes: count("changes"),
70
+ total_decisions: count("decisions"),
71
+ total_file_notes: count("file_notes"),
72
+ total_conventions: count("conventions"),
73
+ total_tasks: count("tasks"),
74
+ total_milestones: count("milestones"),
75
+ oldest_session: oldest?.started_at || null,
76
+ newest_session: newest?.started_at || null,
77
+ most_changed_files: mostChanged,
78
+ database_size_kb: getDbSizeKb(),
79
+ layer_distribution: layerDist,
80
+ tasks_by_status: tasksByStatus,
81
+ schema_version: schemaVersion,
82
+ engine: "better-sqlite3 (WAL mode)",
83
+ };
84
+
85
+ return {
86
+ content: [{ type: "text", text: JSON.stringify(stats, null, 2) }],
87
+ };
88
+ }
89
+ );
90
+
91
+ // ─── COMPACT MEMORY ─────────────────────────────────────────────────
92
+ server.registerTool(
93
+ `${TOOL_PREFIX}_compact`,
94
+ {
95
+ title: "Compact Memory",
96
+ description: `Compact old session data to reduce database size. Merges change records from old sessions into summaries and removes granular entries. Sessions newer than the threshold are preserved in full. Automatically creates a backup before compacting.
97
+
98
+ Args:
99
+ - keep_sessions (number, optional): Number of recent sessions to keep in full detail (default: ${COMPACTION_THRESHOLD_SESSIONS})
100
+ - max_age_days (number, optional): Also remove sessions older than N days (default: no age limit)
101
+ - dry_run (boolean, optional): Show what would be compacted without actually doing it (default: true)
102
+
103
+ Returns:
104
+ CompactionResult with counts and freed storage.`,
105
+ inputSchema: {
106
+ keep_sessions: z.number().int().min(5).default(COMPACTION_THRESHOLD_SESSIONS).describe("Recent sessions to preserve"),
107
+ max_age_days: z.number().int().min(7).optional().describe("Remove sessions older than N days"),
108
+ dry_run: z.boolean().default(true).describe("Preview mode — no changes made"),
109
+ },
110
+ annotations: {
111
+ readOnlyHint: false,
112
+ destructiveHint: true,
113
+ idempotentHint: false,
114
+ openWorldHint: false,
115
+ },
116
+ },
117
+ async ({ keep_sessions, max_age_days, dry_run }) => {
118
+ const db = getDb();
119
+
120
+ // Find the cutoff session ID
121
+ const cutoff = db.prepare(
122
+ "SELECT id FROM sessions ORDER BY id DESC LIMIT 1 OFFSET ?"
123
+ ).get(keep_sessions) as { id: number } | undefined;
124
+
125
+ if (!cutoff) {
126
+ return {
127
+ content: [{ type: "text", text: "Not enough sessions to compact. Nothing to do." }],
128
+ };
129
+ }
130
+
131
+ // Count what would be compacted
132
+ let sessionsQuery = "SELECT COUNT(*) as c FROM sessions WHERE id <= ? AND ended_at IS NOT NULL";
133
+ const sessionsParams: unknown[] = [cutoff.id];
134
+
135
+ if (max_age_days) {
136
+ const cutoffDate = new Date(Date.now() - max_age_days * 86400000).toISOString();
137
+ sessionsQuery += " AND started_at < ?";
138
+ sessionsParams.push(cutoffDate);
139
+ }
140
+
141
+ const sessionsToCompact = (db.prepare(sessionsQuery).get(...sessionsParams) as { c: number }).c;
142
+
143
+ const changesToSummarize = (db.prepare(
144
+ "SELECT COUNT(*) as c FROM changes WHERE session_id <= ?"
145
+ ).get(cutoff.id) as { c: number }).c;
146
+
147
+ const sizeBefore = getDbSizeKb();
148
+
149
+ if (dry_run) {
150
+ return {
151
+ content: [{
152
+ type: "text",
153
+ text: JSON.stringify({
154
+ dry_run: true,
155
+ would_compact: {
156
+ sessions: sessionsToCompact,
157
+ changes: changesToSummarize,
158
+ },
159
+ message: `Would compact ${sessionsToCompact} sessions and summarize ${changesToSummarize} change records. Run with dry_run=false to execute.`,
160
+ }, null, 2),
161
+ }],
162
+ };
163
+ }
164
+
165
+ // ─── Auto-backup before compacting ──────────────────────────
166
+ let backupPath = "";
167
+ try {
168
+ backupPath = backupDatabase();
169
+ console.error(`[Engram] Auto-backup created before compaction: ${backupPath}`);
170
+ } catch (e) {
171
+ console.error(`[Engram] Warning: failed to create backup before compaction: ${e}`);
172
+ }
173
+
174
+ // Execute compaction in a transaction
175
+ const compact = db.transaction(() => {
176
+ // For each old session, create a summarized change record
177
+ const oldSessions = db.prepare(
178
+ "SELECT id, summary FROM sessions WHERE id <= ? AND ended_at IS NOT NULL"
179
+ ).all(cutoff.id) as Array<{ id: number; summary: string | null }>;
180
+
181
+ for (const session of oldSessions) {
182
+ const changes = db.prepare(
183
+ "SELECT change_type, file_path, description FROM changes WHERE session_id = ?"
184
+ ).all(session.id) as Array<{ change_type: string; file_path: string; description: string }>;
185
+
186
+ if (changes.length > 0) {
187
+ // Create one summary change per old session
188
+ const summaryDesc = changes.map(c => `[${c.change_type}] ${c.file_path}: ${c.description}`).join("; ");
189
+ db.prepare(
190
+ "INSERT INTO changes (session_id, timestamp, file_path, change_type, description, impact_scope) VALUES (?, ?, ?, ?, ?, ?)"
191
+ ).run(session.id, now(), "(compacted)", "modified", `Compacted ${changes.length} changes: ${summaryDesc.slice(0, 2000)}`, "global");
192
+ }
193
+
194
+ // Delete granular changes
195
+ db.prepare("DELETE FROM changes WHERE session_id = ? AND file_path != '(compacted)'").run(session.id);
196
+ }
197
+ });
198
+
199
+ compact();
200
+
201
+ // Vacuum to reclaim space (must be outside transaction)
202
+ db.exec("VACUUM");
203
+
204
+ const sizeAfter = getDbSizeKb();
205
+
206
+ const result: CompactionResult = {
207
+ sessions_compacted: sessionsToCompact,
208
+ changes_summarized: changesToSummarize,
209
+ storage_freed_kb: Math.max(0, sizeBefore - sizeAfter),
210
+ };
211
+
212
+ return {
213
+ content: [{
214
+ type: "text",
215
+ text: JSON.stringify({
216
+ ...result,
217
+ backup_path: backupPath || null,
218
+ message: `Compaction complete. ${backupPath ? `Backup saved at ${backupPath}.` : ""}`,
219
+ }, null, 2),
220
+ }],
221
+ };
222
+ }
223
+ );
224
+
225
+ // ─── BACKUP DATABASE ───────────────────────────────────────────────
226
+ server.registerTool(
227
+ `${TOOL_PREFIX}_backup`,
228
+ {
229
+ title: "Backup Database",
230
+ description: `Create a backup of the Engram memory database. Uses SQLite's native backup API for safe, consistent copies. Save to any path — including cloud-synced folders (Dropbox, OneDrive, Google Drive) for cross-machine portability.
231
+
232
+ Args:
233
+ - output_path (string, optional): Where to save the backup (default: .engram/backups/memory-{timestamp}.db)
234
+ - prune_old (boolean, optional): Remove old backups beyond the max count (default: true)
235
+
236
+ Returns:
237
+ BackupInfo with path, size, and timestamp.`,
238
+ inputSchema: {
239
+ output_path: z.string().optional().describe("Custom backup destination path"),
240
+ prune_old: z.boolean().default(true).describe("Prune old backups beyond the max count"),
241
+ },
242
+ annotations: {
243
+ readOnlyHint: false,
244
+ destructiveHint: false,
245
+ idempotentHint: false,
246
+ openWorldHint: true,
247
+ },
248
+ },
249
+ async ({ output_path, prune_old }) => {
250
+ const backupPath = backupDatabase(output_path);
251
+
252
+ const stats = fs.statSync(backupPath);
253
+ const sizeKb = Math.round(stats.size / 1024);
254
+
255
+ // Get schema version
256
+ let dbVersion = 0;
257
+ try {
258
+ const db = getDb();
259
+ const vRow = db.prepare("SELECT value FROM schema_meta WHERE key = 'version'").get() as { value: string } | undefined;
260
+ dbVersion = vRow ? parseInt(vRow.value, 10) : 0;
261
+ } catch { /* skip */ }
262
+
263
+ const info: BackupInfo = {
264
+ path: backupPath,
265
+ size_kb: sizeKb,
266
+ created_at: now(),
267
+ database_version: dbVersion,
268
+ };
269
+
270
+ // Prune old backups if saving to default directory
271
+ if (prune_old && !output_path) {
272
+ const backupDir = path.join(getProjectRoot(), DB_DIR_NAME, BACKUP_DIR_NAME);
273
+ try {
274
+ const files = fs.readdirSync(backupDir)
275
+ .filter(f => f.startsWith("memory-") && f.endsWith(".db"))
276
+ .map(f => ({
277
+ name: f,
278
+ path: path.join(backupDir, f),
279
+ mtime: fs.statSync(path.join(backupDir, f)).mtimeMs,
280
+ }))
281
+ .sort((a, b) => b.mtime - a.mtime);
282
+
283
+ if (files.length > MAX_BACKUP_COUNT) {
284
+ const toDelete = files.slice(MAX_BACKUP_COUNT);
285
+ for (const f of toDelete) {
286
+ fs.unlinkSync(f.path);
287
+ }
288
+ (info as BackupInfo & { pruned: number }).pruned = toDelete.length;
289
+ }
290
+ } catch { /* skip pruning */ }
291
+ }
292
+
293
+ return {
294
+ content: [{
295
+ type: "text",
296
+ text: JSON.stringify({
297
+ ...info,
298
+ message: `Backup created successfully at ${backupPath} (${sizeKb} KB).`,
299
+ }, null, 2),
300
+ }],
301
+ };
302
+ }
303
+ );
304
+
305
+ // ─── RESTORE DATABASE ──────────────────────────────────────────────
306
+ server.registerTool(
307
+ `${TOOL_PREFIX}_restore`,
308
+ {
309
+ title: "Restore Database",
310
+ description: `Restore the Engram memory database from a backup file. Creates a safety backup of the current database before overwriting. The MCP server will need to be restarted after restore.
311
+
312
+ Args:
313
+ - input_path (string): Path to the backup .db file
314
+ - confirm (string): Must be "yes-restore" to execute
315
+
316
+ Returns:
317
+ Confirmation and instructions to restart.`,
318
+ inputSchema: {
319
+ input_path: z.string().describe("Path to the backup .db file"),
320
+ confirm: z.string().describe('Type "yes-restore" to confirm'),
321
+ },
322
+ annotations: {
323
+ readOnlyHint: false,
324
+ destructiveHint: true,
325
+ idempotentHint: false,
326
+ openWorldHint: true,
327
+ },
328
+ },
329
+ async ({ input_path, confirm }) => {
330
+ if (confirm !== "yes-restore") {
331
+ return {
332
+ isError: true,
333
+ content: [{ type: "text", text: 'Safety check: set confirm to "yes-restore" to proceed.' }],
334
+ };
335
+ }
336
+
337
+ const projectRoot = getProjectRoot();
338
+ const inputPath = path.isAbsolute(input_path) ? input_path : path.join(projectRoot, input_path);
339
+
340
+ if (!fs.existsSync(inputPath)) {
341
+ return { isError: true, content: [{ type: "text", text: `Backup file not found: ${inputPath}` }] };
342
+ }
343
+
344
+ // Create safety backup of current database
345
+ let safetyBackupPath = "";
346
+ try {
347
+ safetyBackupPath = backupDatabase();
348
+ console.error(`[Engram] Safety backup created before restore: ${safetyBackupPath}`);
349
+ } catch (e) {
350
+ return {
351
+ isError: true,
352
+ content: [{ type: "text", text: `Failed to create safety backup before restore: ${e}. Aborting.` }],
353
+ };
354
+ }
355
+
356
+ // Copy the backup file over the current database
357
+ const dbPath = getDbPath();
358
+ try {
359
+ fs.copyFileSync(inputPath, dbPath);
360
+ } catch (e) {
361
+ return {
362
+ isError: true,
363
+ content: [{ type: "text", text: `Failed to restore: ${e}. Your previous database is backed up at ${safetyBackupPath}.` }],
364
+ };
365
+ }
366
+
367
+ return {
368
+ content: [{
369
+ type: "text",
370
+ text: JSON.stringify({
371
+ restored_from: inputPath,
372
+ safety_backup: safetyBackupPath,
373
+ message: "Database restored successfully. Please RESTART the MCP server to load the restored database. A safety backup of the previous database was created.",
374
+ }, null, 2),
375
+ }],
376
+ };
377
+ }
378
+ );
379
+
380
+ // ─── LIST BACKUPS ──────────────────────────────────────────────────
381
+ server.registerTool(
382
+ `${TOOL_PREFIX}_list_backups`,
383
+ {
384
+ title: "List Backups",
385
+ description: `List all available backup files in the default backup directory.
386
+
387
+ Returns:
388
+ Array of backup files with sizes and timestamps.`,
389
+ inputSchema: {},
390
+ annotations: {
391
+ readOnlyHint: true,
392
+ destructiveHint: false,
393
+ idempotentHint: true,
394
+ openWorldHint: false,
395
+ },
396
+ },
397
+ async () => {
398
+ const backupDir = path.join(getProjectRoot(), DB_DIR_NAME, BACKUP_DIR_NAME);
399
+
400
+ if (!fs.existsSync(backupDir)) {
401
+ return {
402
+ content: [{ type: "text", text: JSON.stringify({ backups: [], message: "No backups found." }, null, 2) }],
403
+ };
404
+ }
405
+
406
+ const files = fs.readdirSync(backupDir)
407
+ .filter(f => f.endsWith(".db"))
408
+ .map(f => {
409
+ const fullPath = path.join(backupDir, f);
410
+ const stats = fs.statSync(fullPath);
411
+ return {
412
+ filename: f,
413
+ path: fullPath,
414
+ size_kb: Math.round(stats.size / 1024),
415
+ created_at: stats.mtime.toISOString(),
416
+ };
417
+ })
418
+ .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
419
+
420
+ return {
421
+ content: [{
422
+ type: "text",
423
+ text: JSON.stringify({
424
+ backup_directory: backupDir,
425
+ count: files.length,
426
+ backups: files,
427
+ }, null, 2),
428
+ }],
429
+ };
430
+ }
431
+ );
432
+
433
+ // ─── RECORD MILESTONE ───────────────────────────────────────────────
434
+ server.registerTool(
435
+ `${TOOL_PREFIX}_record_milestone`,
436
+ {
437
+ title: "Record Milestone",
438
+ description: `Record a major project milestone or achievement. Milestones mark significant points in the project timeline — feature completions, releases, major refactors, etc.
439
+
440
+ Args:
441
+ - title (string): Milestone title
442
+ - description (string, optional): What was achieved
443
+ - version (string, optional): Version number if applicable
444
+ - tags (array, optional): Tags
445
+
446
+ Returns:
447
+ Milestone ID and confirmation.`,
448
+ inputSchema: {
449
+ title: z.string().min(3).describe("Milestone title"),
450
+ description: z.string().optional().describe("What was achieved"),
451
+ version: z.string().optional().describe("Version number"),
452
+ tags: z.array(z.string()).optional(),
453
+ },
454
+ annotations: {
455
+ readOnlyHint: false,
456
+ destructiveHint: false,
457
+ idempotentHint: false,
458
+ openWorldHint: false,
459
+ },
460
+ },
461
+ async ({ title, description, version, tags }) => {
462
+ const db = getDb();
463
+ const timestamp = now();
464
+ const sessionId = getCurrentSessionId();
465
+
466
+ const result = db.prepare(
467
+ "INSERT INTO milestones (session_id, timestamp, title, description, version, tags) VALUES (?, ?, ?, ?, ?, ?)"
468
+ ).run(sessionId, timestamp, title, description || null, version || null, tags ? JSON.stringify(tags) : null);
469
+
470
+ return {
471
+ content: [{
472
+ type: "text",
473
+ text: JSON.stringify({
474
+ milestone_id: result.lastInsertRowid,
475
+ message: `Milestone #${result.lastInsertRowid} recorded: "${title}"${version ? ` (v${version})` : ""}`,
476
+ }, null, 2),
477
+ }],
478
+ };
479
+ }
480
+ );
481
+
482
+ server.registerTool(
483
+ `${TOOL_PREFIX}_get_milestones`,
484
+ {
485
+ title: "Get Milestones",
486
+ description: `Retrieve project milestones. Shows the project's achievement timeline.
487
+
488
+ Args:
489
+ - limit (number, optional): Max results (default 20)
490
+
491
+ Returns:
492
+ Array of milestones in reverse chronological order.`,
493
+ inputSchema: {
494
+ limit: z.number().int().min(1).max(100).default(20),
495
+ },
496
+ annotations: {
497
+ readOnlyHint: true,
498
+ destructiveHint: false,
499
+ idempotentHint: true,
500
+ openWorldHint: false,
501
+ },
502
+ },
503
+ async ({ limit }) => {
504
+ const db = getDb();
505
+ const milestones = db.prepare("SELECT * FROM milestones ORDER BY timestamp DESC LIMIT ?").all(limit);
506
+ return { content: [{ type: "text", text: JSON.stringify({ milestones }, null, 2) }] };
507
+ }
508
+ );
509
+
510
+ // ─── EXPORT MEMORY ──────────────────────────────────────────────────
511
+ server.registerTool(
512
+ `${TOOL_PREFIX}_export`,
513
+ {
514
+ title: "Export Memory",
515
+ description: `Export the entire memory database as a portable JSON file. Useful for backup, migration, or sharing project knowledge with teammates.
516
+
517
+ Args:
518
+ - output_path (string, optional): Where to save the export (default: .engram/export.json)
519
+
520
+ Returns:
521
+ Export file path and summary.`,
522
+ inputSchema: {
523
+ output_path: z.string().optional().describe("Export file path"),
524
+ },
525
+ annotations: {
526
+ readOnlyHint: true,
527
+ destructiveHint: false,
528
+ idempotentHint: true,
529
+ openWorldHint: true,
530
+ },
531
+ },
532
+ async ({ output_path }) => {
533
+ const db = getDb();
534
+ const projectRoot = getProjectRoot();
535
+
536
+ const exportData = {
537
+ engram_version: SERVER_VERSION,
538
+ exported_at: now(),
539
+ project_root: projectRoot,
540
+ sessions: db.prepare("SELECT * FROM sessions ORDER BY id").all(),
541
+ changes: db.prepare("SELECT * FROM changes ORDER BY id").all(),
542
+ decisions: db.prepare("SELECT * FROM decisions ORDER BY id").all(),
543
+ file_notes: db.prepare("SELECT * FROM file_notes ORDER BY file_path").all(),
544
+ conventions: db.prepare("SELECT * FROM conventions ORDER BY id").all(),
545
+ tasks: db.prepare("SELECT * FROM tasks ORDER BY id").all(),
546
+ milestones: db.prepare("SELECT * FROM milestones ORDER BY id").all(),
547
+ };
548
+
549
+ const filePath = output_path || path.join(projectRoot, DB_DIR_NAME, "export.json");
550
+ fs.writeFileSync(filePath, JSON.stringify(exportData, null, 2));
551
+
552
+ return {
553
+ content: [{
554
+ type: "text",
555
+ text: JSON.stringify({
556
+ exported_to: filePath,
557
+ counts: {
558
+ sessions: (exportData.sessions as unknown[]).length,
559
+ changes: (exportData.changes as unknown[]).length,
560
+ decisions: (exportData.decisions as unknown[]).length,
561
+ file_notes: (exportData.file_notes as unknown[]).length,
562
+ conventions: (exportData.conventions as unknown[]).length,
563
+ tasks: (exportData.tasks as unknown[]).length,
564
+ milestones: (exportData.milestones as unknown[]).length,
565
+ },
566
+ }, null, 2),
567
+ }],
568
+ };
569
+ }
570
+ );
571
+
572
+ // ─── IMPORT MEMORY ──────────────────────────────────────────────────
573
+ server.registerTool(
574
+ `${TOOL_PREFIX}_import`,
575
+ {
576
+ title: "Import Memory",
577
+ description: `Import memory from a previously exported JSON file. Merges data into the existing database without duplicating existing records.
578
+
579
+ Args:
580
+ - input_path (string): Path to the export JSON file
581
+ - dry_run (boolean, optional): Preview import without writing (default: true)
582
+
583
+ Returns:
584
+ Import summary with counts.`,
585
+ inputSchema: {
586
+ input_path: z.string().describe("Path to export JSON file"),
587
+ dry_run: z.boolean().default(true).describe("Preview mode"),
588
+ },
589
+ annotations: {
590
+ readOnlyHint: false,
591
+ destructiveHint: false,
592
+ idempotentHint: true,
593
+ openWorldHint: true,
594
+ },
595
+ },
596
+ async ({ input_path, dry_run }) => {
597
+ const projectRoot = getProjectRoot();
598
+ const filePath = path.isAbsolute(input_path) ? input_path : path.join(projectRoot, input_path);
599
+
600
+ if (!fs.existsSync(filePath)) {
601
+ return { isError: true, content: [{ type: "text", text: `File not found: ${filePath}` }] };
602
+ }
603
+
604
+ let importData: Record<string, unknown[]>;
605
+ try {
606
+ importData = JSON.parse(fs.readFileSync(filePath, "utf-8"));
607
+ } catch (e) {
608
+ return { isError: true, content: [{ type: "text", text: `Invalid JSON: ${e}` }] };
609
+ }
610
+
611
+ const counts: Record<string, number> = {};
612
+ for (const key of ["sessions", "changes", "decisions", "file_notes", "conventions", "tasks", "milestones"]) {
613
+ counts[key] = Array.isArray(importData[key]) ? importData[key].length : 0;
614
+ }
615
+
616
+ if (dry_run) {
617
+ return {
618
+ content: [{
619
+ type: "text",
620
+ text: JSON.stringify({
621
+ dry_run: true,
622
+ would_import: counts,
623
+ message: "Run with dry_run=false to execute the import.",
624
+ }, null, 2),
625
+ }],
626
+ };
627
+ }
628
+
629
+ // Actual import — decisions, conventions, file_notes, tasks, milestones
630
+ const db = getDb();
631
+
632
+ const importTransaction = db.transaction(() => {
633
+ // ─── Import sessions (skip duplicates by started_at + agent_name) ───
634
+ const sessionIdMap = new Map<number, number>(); // old → new
635
+ if (Array.isArray(importData.sessions)) {
636
+ for (const s of importData.sessions as Array<Record<string, unknown>>) {
637
+ const exists = db.prepare(
638
+ "SELECT id FROM sessions WHERE started_at = ? AND agent_name = ?"
639
+ ).get(s.started_at, s.agent_name || "unknown") as { id: number } | undefined;
640
+ if (!exists) {
641
+ const result = db.prepare(
642
+ "INSERT INTO sessions (started_at, ended_at, summary, agent_name, project_root, tags) VALUES (?, ?, ?, ?, ?, ?)"
643
+ ).run(s.started_at, s.ended_at || null, s.summary || null, s.agent_name || "unknown", s.project_root || "", s.tags || null);
644
+ sessionIdMap.set(s.id as number, result.lastInsertRowid as number);
645
+ } else {
646
+ sessionIdMap.set(s.id as number, exists.id);
647
+ }
648
+ }
649
+ }
650
+
651
+ // ─── Import changes (skip duplicates by file_path + timestamp + description) ───
652
+ if (Array.isArray(importData.changes)) {
653
+ for (const c of importData.changes as Array<Record<string, unknown>>) {
654
+ const exists = db.prepare(
655
+ "SELECT id FROM changes WHERE file_path = ? AND timestamp = ? AND description = ?"
656
+ ).get(c.file_path, c.timestamp, c.description);
657
+ if (!exists) {
658
+ // Map old session_id to new session_id
659
+ const mappedSessionId = c.session_id ? (sessionIdMap.get(c.session_id as number) ?? c.session_id) : null;
660
+ db.prepare(
661
+ "INSERT INTO changes (session_id, timestamp, file_path, change_type, description, diff_summary, impact_scope) VALUES (?, ?, ?, ?, ?, ?, ?)"
662
+ ).run(mappedSessionId, c.timestamp, c.file_path, c.change_type, c.description, c.diff_summary || null, c.impact_scope || "local");
663
+ }
664
+ }
665
+ }
666
+
667
+ // Import conventions (skip duplicates by rule text)
668
+ if (Array.isArray(importData.conventions)) {
669
+ for (const c of importData.conventions as Array<Record<string, unknown>>) {
670
+ const exists = db.prepare("SELECT id FROM conventions WHERE rule = ?").get(c.rule);
671
+ if (!exists) {
672
+ db.prepare(
673
+ "INSERT INTO conventions (timestamp, category, rule, examples, enforced) VALUES (?, ?, ?, ?, ?)"
674
+ ).run(c.timestamp || now(), c.category, c.rule, c.examples || null, c.enforced ?? 1);
675
+ }
676
+ }
677
+ }
678
+
679
+ // Import decisions (skip duplicates by decision text)
680
+ if (Array.isArray(importData.decisions)) {
681
+ for (const d of importData.decisions as Array<Record<string, unknown>>) {
682
+ const exists = db.prepare("SELECT id FROM decisions WHERE decision = ?").get(d.decision);
683
+ if (!exists) {
684
+ db.prepare(
685
+ "INSERT INTO decisions (timestamp, decision, rationale, affected_files, tags, status) VALUES (?, ?, ?, ?, ?, ?)"
686
+ ).run(d.timestamp || now(), d.decision, d.rationale || null, d.affected_files || null, d.tags || null, d.status || "active");
687
+ }
688
+ }
689
+ }
690
+
691
+ // Import file notes (upsert)
692
+ if (Array.isArray(importData.file_notes)) {
693
+ for (const f of importData.file_notes as Array<Record<string, unknown>>) {
694
+ db.prepare(`
695
+ INSERT OR REPLACE INTO file_notes (file_path, purpose, dependencies, dependents, layer, last_reviewed, notes, complexity)
696
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
697
+ `).run(f.file_path, f.purpose || null, f.dependencies || null, f.dependents || null, f.layer || null, f.last_reviewed || now(), f.notes || null, f.complexity || null);
698
+ }
699
+ }
700
+
701
+ // Import milestones (skip duplicates by title + timestamp)
702
+ if (Array.isArray(importData.milestones)) {
703
+ for (const m of importData.milestones as Array<Record<string, unknown>>) {
704
+ const exists = db.prepare("SELECT id FROM milestones WHERE title = ? AND timestamp = ?").get(m.title, m.timestamp);
705
+ if (!exists) {
706
+ db.prepare(
707
+ "INSERT INTO milestones (timestamp, title, description, version, tags) VALUES (?, ?, ?, ?, ?)"
708
+ ).run(m.timestamp || now(), m.title, m.description || null, m.version || null, m.tags || null);
709
+ }
710
+ }
711
+ }
712
+ });
713
+
714
+ importTransaction();
715
+
716
+ return {
717
+ content: [{
718
+ type: "text",
719
+ text: JSON.stringify({ imported: counts, message: "Import complete. Duplicates were skipped." }, null, 2),
720
+ }],
721
+ };
722
+ }
723
+ );
724
+
725
+ // ─── CLEAR MEMORY ───────────────────────────────────────────────────
726
+ server.registerTool(
727
+ `${TOOL_PREFIX}_clear`,
728
+ {
729
+ title: "Clear Memory",
730
+ description: `Clear specific tables or the entire memory database. USE WITH EXTREME CAUTION. This is irreversible. A backup is automatically created before clearing.
731
+
732
+ Args:
733
+ - scope: "all" | "sessions" | "changes" | "decisions" | "file_notes" | "conventions" | "tasks" | "milestones" | "cache"
734
+ - confirm (string): Must be "yes-delete-permanently" to execute
735
+
736
+ Returns:
737
+ Confirmation of what was cleared.`,
738
+ inputSchema: {
739
+ scope: z.enum(["all", "sessions", "changes", "decisions", "file_notes", "conventions", "tasks", "milestones", "cache"]),
740
+ confirm: z.string().describe('Type "yes-delete-permanently" to confirm'),
741
+ },
742
+ annotations: {
743
+ readOnlyHint: false,
744
+ destructiveHint: true,
745
+ idempotentHint: true,
746
+ openWorldHint: false,
747
+ },
748
+ },
749
+ async ({ scope, confirm }) => {
750
+ if (confirm !== "yes-delete-permanently") {
751
+ return {
752
+ isError: true,
753
+ content: [{ type: "text", text: 'Safety check: set confirm to "yes-delete-permanently" to proceed.' }],
754
+ };
755
+ }
756
+
757
+ // ─── Auto-backup before clearing ──────────────────────────
758
+ let backupPath = "";
759
+ try {
760
+ backupPath = backupDatabase();
761
+ console.error(`[Engram] Auto-backup created before clear: ${backupPath}`);
762
+ } catch (e) {
763
+ console.error(`[Engram] Warning: failed to create backup before clear: ${e}`);
764
+ }
765
+
766
+ const db = getDb();
767
+ const tables = scope === "all"
768
+ ? ["sessions", "changes", "decisions", "file_notes", "conventions", "tasks", "milestones", "snapshot_cache"]
769
+ : scope === "cache" ? ["snapshot_cache"] : [scope];
770
+
771
+ for (const table of tables) {
772
+ db.prepare(`DELETE FROM ${table}`).run();
773
+ }
774
+
775
+ return {
776
+ content: [{
777
+ type: "text",
778
+ text: `Cleared: ${tables.join(", ")}. Memory has been reset.${backupPath ? ` Backup saved at ${backupPath}.` : ""}`,
779
+ }],
780
+ };
781
+ }
782
+ );
783
+ }