mcp-memory-keeper 0.13.0 → 0.14.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/CHANGELOG.md CHANGED
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.14.0] - 2026-06-05
11
+
12
+ ### Added
13
+
14
+ - **Checkpoints now survive an export → import round trip** (#37, follow-up to #36)
15
+ - `context_export` writes a new `0.5.0` format that includes the `checkpointItems` and `checkpointFiles` join rows (previously only the bare `checkpoints` rows were exported, which made imported checkpoints empty/useless).
16
+ - `context_import` restores checkpoints and rewires their links: each checkpoint gets a fresh id, and its item/file links are remapped onto the freshly-imported context-item and file-cache ids. Links pointing at a skipped or absent row are dropped rather than aborting the import. The whole restore happens inside the existing atomic transaction.
17
+ - The import summary now reports `Checkpoints: N (skipped M malformed), links restored: K`.
18
+ - **Backward compatible:** importing a pre-`0.5.0` export (no join-row arrays) does not error and reports the checkpoints as not imported, preserving the prior explicit-not-imported behaviour.
19
+ - The entry-count cap from #36 now also bounds checkpoint and join-row arrays.
20
+ - **Merge safety:** on a key/path collision during `merge` import, the existing row is now UPDATED in place (its id preserved) instead of `INSERT OR REPLACE`, so a pre-existing checkpoint's links to that row are no longer cascade-deleted. Both `context_items` and `file_cache` (which have UNIQUE constraints) are handled this way.
21
+ - **Fidelity & observability:** `file_cache.size` is restored on import; duplicate-key/path rows that collapse during import are reported (`collapsed N duplicate-key`); dropped dangling checkpoint links are surfaced (`links restored: K (dropped N dangling)`); and `metadata.totalSize` now accounts for the checkpoint join arrays.
22
+
10
23
  ## [0.13.0] - 2026-06-05
11
24
 
12
25
  ### Fixed
@@ -0,0 +1,303 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ const globals_1 = require("@jest/globals");
37
+ const child_process_1 = require("child_process");
38
+ const fs = __importStar(require("fs"));
39
+ const path = __importStar(require("path"));
40
+ const os = __importStar(require("os"));
41
+ /**
42
+ * E2E tests for GitHub issue #37: checkpoints (and their checkpoint_items /
43
+ * checkpoint_files join rows) must survive a context_export -> context_import
44
+ * round trip.
45
+ *
46
+ * Before the fix, exports carried the `checkpoints` rows but not the join rows,
47
+ * and import restored neither — so a re-imported session lost its checkpoints.
48
+ * The 0.5.0 export format adds `checkpointItems` / `checkpointFiles`, and import
49
+ * rewires their foreign keys onto the freshly-generated ids.
50
+ *
51
+ * The round-trip test imports into a SEPARATE server instance (its own
52
+ * DATA_DIR), so the only checkpoint named "cp-roundtrip" is the imported one —
53
+ * otherwise context_restore_checkpoint (which matches by name across all
54
+ * checkpoints) could resolve the original source checkpoint and mask a broken
55
+ * import.
56
+ *
57
+ * @see https://github.com/mkreyman/mcp-memory-keeper/issues/37
58
+ */
59
+ const SERVER_ENTRY = path.join(__dirname, '../../../dist/index.js');
60
+ async function startServer() {
61
+ const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-checkpoint-'));
62
+ const exportsDir = path.join(dataDir, 'exports');
63
+ const proc = (0, child_process_1.spawn)('node', [SERVER_ENTRY], {
64
+ env: { ...process.env, DATA_DIR: dataDir },
65
+ stdio: ['pipe', 'pipe', 'pipe'],
66
+ });
67
+ if (global.testProcesses) {
68
+ global.testProcesses.push(proc);
69
+ }
70
+ // A single persistent reader dispatches each response to its pending request
71
+ // by id, so concurrent calls cannot share/consume each other's buffer.
72
+ const pending = new Map();
73
+ let buffer = '';
74
+ let nextId = 0;
75
+ proc.stdout?.on('data', (data) => {
76
+ buffer += data.toString();
77
+ const lines = buffer.split('\n');
78
+ buffer = lines.pop() || '';
79
+ for (const line of lines) {
80
+ if (!line.trim())
81
+ continue;
82
+ let msg;
83
+ try {
84
+ msg = JSON.parse(line);
85
+ }
86
+ catch {
87
+ continue;
88
+ }
89
+ if (msg.id != null && pending.has(msg.id)) {
90
+ const p = pending.get(msg.id);
91
+ clearTimeout(p.timer);
92
+ pending.delete(msg.id);
93
+ p.resolve(msg);
94
+ }
95
+ }
96
+ });
97
+ const send = (method, params) => new Promise((resolve, reject) => {
98
+ const reqId = ++nextId;
99
+ const timer = setTimeout(() => {
100
+ pending.delete(reqId);
101
+ reject(new Error(`Timeout waiting for ${method} (id=${reqId})`));
102
+ }, 5000);
103
+ pending.set(reqId, { resolve, timer });
104
+ proc.stdin?.write(JSON.stringify({ jsonrpc: '2.0', method, params, id: reqId }) + '\n');
105
+ });
106
+ const init = await send('initialize', {
107
+ protocolVersion: '2024-11-05',
108
+ capabilities: {},
109
+ clientInfo: { name: 'checkpoint-e2e', version: '1.0.0' },
110
+ });
111
+ (0, globals_1.expect)(init.result).toHaveProperty('protocolVersion');
112
+ proc.stdin?.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n');
113
+ await new Promise(resolve => setTimeout(resolve, 150));
114
+ const call = async (name, args) => {
115
+ const res = await send('tools/call', { name, arguments: args });
116
+ return res.result?.content?.[0]?.text ?? JSON.stringify(res);
117
+ };
118
+ const close = async () => {
119
+ for (const p of pending.values())
120
+ clearTimeout(p.timer);
121
+ pending.clear();
122
+ if (!proc.killed) {
123
+ proc.kill('SIGTERM');
124
+ await new Promise(resolve => {
125
+ const t = setTimeout(() => {
126
+ proc.kill('SIGKILL');
127
+ resolve();
128
+ }, 3000);
129
+ proc.on('exit', () => {
130
+ clearTimeout(t);
131
+ resolve();
132
+ });
133
+ });
134
+ proc.removeAllListeners();
135
+ }
136
+ try {
137
+ fs.rmSync(dataDir, { recursive: true, force: true });
138
+ }
139
+ catch {
140
+ // ignore
141
+ }
142
+ };
143
+ return { proc, dataDir, exportsDir, call, close };
144
+ }
145
+ /** Pull the export file path out of a context_export success message. */
146
+ function parseExportPath(exportText) {
147
+ const match = exportText.match(/to:\s*(.+?\.json)/);
148
+ if (!match)
149
+ throw new Error(`Could not parse export path from: ${exportText}`);
150
+ return match[1];
151
+ }
152
+ (0, globals_1.describe)('E2E: checkpoint export/import round trip (issue #37)', () => {
153
+ let server;
154
+ (0, globals_1.beforeAll)(async () => {
155
+ server = await startServer();
156
+ }, 15000);
157
+ (0, globals_1.afterAll)(async () => {
158
+ await server?.close();
159
+ });
160
+ (0, globals_1.it)('restores a checkpoint and its linked items + files into a clean instance', async () => {
161
+ // --- Source instance: seed, checkpoint, export ---
162
+ await server.call('context_session_start', { name: 'cp-source' });
163
+ await server.call('context_save', { key: 'cp_item_a', value: 'value_a', category: 'task' });
164
+ await server.call('context_save', { key: 'cp_item_b', value: 'value_b', category: 'note' });
165
+ await server.call('context_cache_file', {
166
+ filePath: '/tmp/checkpoint-test.ts',
167
+ content: 'cached file content',
168
+ });
169
+ const cpText = await server.call('context_checkpoint', {
170
+ name: 'cp-roundtrip',
171
+ description: 'round trip checkpoint',
172
+ includeFiles: true,
173
+ includeGitStatus: false,
174
+ });
175
+ (0, globals_1.expect)(cpText).toMatch(/Created checkpoint/i);
176
+ (0, globals_1.expect)(cpText).toMatch(/Context items:\s*2\b/);
177
+ const exportText = await server.call('context_export', { confirmEmpty: true });
178
+ const exportPath = parseExportPath(exportText);
179
+ const exportBytes = fs.readFileSync(exportPath);
180
+ // --- Fresh instance: import the file, so the imported checkpoint is the
181
+ // ONLY one named "cp-roundtrip" (unambiguous restore-by-name). ---
182
+ const importer = await startServer();
183
+ try {
184
+ fs.mkdirSync(importer.exportsDir, { recursive: true });
185
+ const importFile = path.join(importer.exportsDir, 'incoming.json');
186
+ fs.writeFileSync(importFile, exportBytes);
187
+ const importText = await importer.call('context_import', { filePath: 'incoming.json' });
188
+ (0, globals_1.expect)(importText).toMatch(/Import successful/i);
189
+ (0, globals_1.expect)(importText).toMatch(/Context items:\s*2\b/);
190
+ // 2 item links + 1 file link = 3, none dropped.
191
+ (0, globals_1.expect)(importText).toMatch(/Checkpoints:\s*1, links restored:\s*3$/m);
192
+ // End-to-end proof: restoring reproduces the items AND the file, which can
193
+ // only be true if the join rows were rewired onto the freshly-imported
194
+ // rows in THIS instance.
195
+ const restoreText = await importer.call('context_restore_checkpoint', {
196
+ name: 'cp-roundtrip',
197
+ restoreFiles: true,
198
+ });
199
+ (0, globals_1.expect)(restoreText).toMatch(/Successfully restored from checkpoint/i);
200
+ (0, globals_1.expect)(restoreText).toMatch(/Context items:\s*2\b/);
201
+ (0, globals_1.expect)(restoreText).toMatch(/Files:\s*1\b/);
202
+ }
203
+ finally {
204
+ await importer.close();
205
+ }
206
+ }, 20000);
207
+ (0, globals_1.it)('reports legacy exports (no checkpointItems) as not imported, without error', async () => {
208
+ const legacy = path.join(server.exportsDir, 'legacy-export.json');
209
+ fs.mkdirSync(server.exportsDir, { recursive: true });
210
+ fs.writeFileSync(legacy, JSON.stringify({
211
+ version: '0.4.0',
212
+ session: { name: 'legacy', branch: 'main' },
213
+ contextItems: [{ id: 'old-1', key: 'legacy_key', value: 'legacy_value' }],
214
+ fileCache: [],
215
+ checkpoints: [{ id: 'old-cp', session_id: 'old-sess', name: 'legacy-cp' }],
216
+ // no checkpointItems / checkpointFiles keys
217
+ }));
218
+ try {
219
+ const text = await server.call('context_import', { filePath: 'legacy-export.json' });
220
+ (0, globals_1.expect)(text).toMatch(/Import successful/i);
221
+ (0, globals_1.expect)(text).toMatch(/Context items:\s*1\b/);
222
+ (0, globals_1.expect)(text).toMatch(/Checkpoints in file:\s*1 \(not imported/i);
223
+ }
224
+ finally {
225
+ fs.rmSync(legacy, { force: true });
226
+ }
227
+ });
228
+ (0, globals_1.it)('drops checkpoint links that point at skipped/absent rows and proves DB state', async () => {
229
+ // 0.5.0 export with a checkpoint whose item link references an id that is
230
+ // not present in contextItems — the checkpoint imports, the dangling link is
231
+ // dropped, and a subsequent restore yields exactly the one valid item.
232
+ const partial = path.join(server.exportsDir, 'partial-cp.json');
233
+ fs.mkdirSync(server.exportsDir, { recursive: true });
234
+ fs.writeFileSync(partial, JSON.stringify({
235
+ version: '0.5.0',
236
+ session: { name: 'partial', branch: 'main' },
237
+ contextItems: [{ id: 'item-1', key: 'k1', value: 'v1' }],
238
+ fileCache: [],
239
+ checkpoints: [{ id: 'cp-1', session_id: 's', name: 'partial-cp' }],
240
+ checkpointItems: [
241
+ { id: 'l1', checkpoint_id: 'cp-1', context_item_id: 'item-1' }, // valid
242
+ { id: 'l2', checkpoint_id: 'cp-1', context_item_id: 'missing' }, // dangling
243
+ ],
244
+ checkpointFiles: [],
245
+ }));
246
+ try {
247
+ const text = await server.call('context_import', { filePath: 'partial-cp.json' });
248
+ (0, globals_1.expect)(text).toMatch(/Import successful/i);
249
+ // 1 checkpoint, only the 1 valid link restored, 1 dangling link dropped.
250
+ (0, globals_1.expect)(text).toMatch(/Checkpoints:\s*1, links restored:\s*1 \(dropped 1 dangling\)/);
251
+ // "partial-cp" is unique here, so restore-by-name is unambiguous.
252
+ const restoreText = await server.call('context_restore_checkpoint', {
253
+ name: 'partial-cp',
254
+ restoreFiles: true,
255
+ });
256
+ (0, globals_1.expect)(restoreText).toMatch(/Context items:\s*1\b/); // not 2 — dangling link was dropped
257
+ }
258
+ finally {
259
+ fs.rmSync(partial, { force: true });
260
+ }
261
+ });
262
+ (0, globals_1.it)('merge import updates a colliding item in place and preserves a pre-existing checkpoint link', async () => {
263
+ // Regression guard: a merge import whose item key collides with an existing
264
+ // row must UPDATE in place (preserving the row id) rather than INSERT OR
265
+ // REPLACE. REPLACE would cascade-delete the existing checkpoint's link.
266
+ await server.call('context_session_start', { name: 'merge-target' });
267
+ await server.call('context_save', { key: 'shared_key', value: 'original', category: 'task' });
268
+ const cp = await server.call('context_checkpoint', {
269
+ name: 'pre-merge-cp',
270
+ includeFiles: false,
271
+ includeGitStatus: false,
272
+ });
273
+ (0, globals_1.expect)(cp).toMatch(/Context items:\s*1\b/);
274
+ const mergeFile = path.join(server.exportsDir, 'merge-in.json');
275
+ fs.mkdirSync(server.exportsDir, { recursive: true });
276
+ fs.writeFileSync(mergeFile, JSON.stringify({
277
+ version: '0.5.0',
278
+ session: { name: 'incoming', branch: 'main' },
279
+ contextItems: [{ id: 'x', key: 'shared_key', value: 'updated' }],
280
+ fileCache: [],
281
+ checkpoints: [],
282
+ checkpointItems: [],
283
+ checkpointFiles: [],
284
+ }));
285
+ try {
286
+ const imp = await server.call('context_import', { filePath: 'merge-in.json', merge: true });
287
+ (0, globals_1.expect)(imp).toMatch(/Mode:\s*Merged/);
288
+ // The colliding item was updated in place (value changed).
289
+ const got = await server.call('context_get', { key: 'shared_key' });
290
+ (0, globals_1.expect)(got).toContain('updated');
291
+ // The pre-existing checkpoint still restores its 1 item — the link
292
+ // survived because the row id was preserved (no REPLACE-cascade).
293
+ const restore = await server.call('context_restore_checkpoint', {
294
+ name: 'pre-merge-cp',
295
+ restoreFiles: false,
296
+ });
297
+ (0, globals_1.expect)(restore).toMatch(/Context items:\s*1\b/);
298
+ }
299
+ finally {
300
+ fs.rmSync(mergeFile, { force: true });
301
+ }
302
+ });
303
+ });
package/dist/index.js CHANGED
@@ -145,6 +145,80 @@ function resolveConfinedImportPath(filePath) {
145
145
  }
146
146
  return resolved;
147
147
  }
148
+ /**
149
+ * Restore checkpoints and their checkpoint_items / checkpoint_files join rows
150
+ * from a 0.5.0+ import payload. MUST be called inside the import transaction so
151
+ * its inserts are atomic with the rest of the import.
152
+ *
153
+ * Each checkpoint gets a fresh id; join rows are rewired onto the new item/file
154
+ * ids via the supplied maps. Malformed checkpoints are skipped, and links
155
+ * pointing at a skipped or absent row are dropped (never inserted — that would
156
+ * violate a foreign key).
157
+ */
158
+ function restoreImportedCheckpoints(db, targetSessionId, checkpointEntries, checkpointItemEntries, checkpointFileEntries, itemIdMap, fileIdMap) {
159
+ let checkpointCount = 0;
160
+ let skippedCheckpoints = 0;
161
+ let checkpointLinkCount = 0;
162
+ let droppedCheckpointLinks = 0;
163
+ const checkpointIdMap = new Map();
164
+ const cpStmt = db.prepare(`
165
+ INSERT INTO checkpoints (id, session_id, name, description, metadata, git_status, git_branch, created_at)
166
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
167
+ `);
168
+ for (const entry of checkpointEntries) {
169
+ const cp = entry;
170
+ if (typeof cp !== 'object' ||
171
+ cp === null ||
172
+ typeof cp.id !== 'string' ||
173
+ typeof cp.name !== 'string') {
174
+ skippedCheckpoints++;
175
+ continue;
176
+ }
177
+ const newCpId = (0, uuid_1.v4)();
178
+ checkpointIdMap.set(cp.id, newCpId);
179
+ cpStmt.run(newCpId, targetSessionId, cp.name, typeof cp.description === 'string' ? cp.description : null, typeof cp.metadata === 'string' ? cp.metadata : null, typeof cp.git_status === 'string' ? cp.git_status : null, typeof cp.git_branch === 'string' ? cp.git_branch : null, typeof cp.created_at === 'string' ? cp.created_at : new Date().toISOString());
180
+ checkpointCount++;
181
+ }
182
+ const cpiStmt = db.prepare('INSERT INTO checkpoint_items (id, checkpoint_id, context_item_id) VALUES (?, ?, ?)');
183
+ for (const entry of checkpointItemEntries) {
184
+ const link = entry;
185
+ if (typeof link !== 'object' ||
186
+ link === null ||
187
+ typeof link.checkpoint_id !== 'string' ||
188
+ typeof link.context_item_id !== 'string') {
189
+ droppedCheckpointLinks++;
190
+ continue;
191
+ }
192
+ const newCpId = checkpointIdMap.get(link.checkpoint_id);
193
+ const newItemId = itemIdMap.get(link.context_item_id);
194
+ if (!newCpId || !newItemId) {
195
+ droppedCheckpointLinks++;
196
+ continue;
197
+ }
198
+ cpiStmt.run((0, uuid_1.v4)(), newCpId, newItemId);
199
+ checkpointLinkCount++;
200
+ }
201
+ const cpfStmt = db.prepare('INSERT INTO checkpoint_files (id, checkpoint_id, file_cache_id) VALUES (?, ?, ?)');
202
+ for (const entry of checkpointFileEntries) {
203
+ const link = entry;
204
+ if (typeof link !== 'object' ||
205
+ link === null ||
206
+ typeof link.checkpoint_id !== 'string' ||
207
+ typeof link.file_cache_id !== 'string') {
208
+ droppedCheckpointLinks++;
209
+ continue;
210
+ }
211
+ const newCpId = checkpointIdMap.get(link.checkpoint_id);
212
+ const newFileId = fileIdMap.get(link.file_cache_id);
213
+ if (!newCpId || !newFileId) {
214
+ droppedCheckpointLinks++;
215
+ continue;
216
+ }
217
+ cpfStmt.run((0, uuid_1.v4)(), newCpId, newFileId);
218
+ checkpointLinkCount++;
219
+ }
220
+ return { checkpointCount, skippedCheckpoints, checkpointLinkCount, droppedCheckpointLinks };
221
+ }
148
222
  // Warn users whose legacy DB is sitting in CWD
149
223
  const legacyDb = path.join(process.cwd(), 'context.db');
150
224
  if (process.cwd() !== dataDir && fs.existsSync(legacyDb)) {
@@ -1409,6 +1483,19 @@ Checkpoint: ${autoSave ? `git-commit-${new Date().toISOString()}` : 'None'}`,
1409
1483
  const checkpoints = db
1410
1484
  .prepare('SELECT * FROM checkpoints WHERE session_id = ?')
1411
1485
  .all(targetSessionId);
1486
+ // The join rows that link a checkpoint to the context items / files it
1487
+ // captured. Without these, an imported checkpoint would be empty, so they
1488
+ // are part of the export payload (format version 0.5.0+).
1489
+ const checkpointItems = db
1490
+ .prepare(`SELECT cpi.* FROM checkpoint_items cpi
1491
+ JOIN checkpoints cp ON cpi.checkpoint_id = cp.id
1492
+ WHERE cp.session_id = ?`)
1493
+ .all(targetSessionId);
1494
+ const checkpointFiles = db
1495
+ .prepare(`SELECT cpf.* FROM checkpoint_files cpf
1496
+ JOIN checkpoints cp ON cpf.checkpoint_id = cp.id
1497
+ WHERE cp.session_id = ?`)
1498
+ .all(targetSessionId);
1412
1499
  // Check if session is empty
1413
1500
  const isEmpty = contextItems.length === 0 && fileCache.length === 0 && checkpoints.length === 0;
1414
1501
  if (isEmpty && !confirmEmpty) {
@@ -1424,17 +1511,28 @@ Checkpoint: ${autoSave ? `git-commit-${new Date().toISOString()}` : 'None'}`,
1424
1511
  };
1425
1512
  }
1426
1513
  const exportData = {
1427
- version: '0.4.0',
1514
+ // 0.5.0 added checkpointItems / checkpointFiles so checkpoints survive
1515
+ // a round trip. Older importers ignore the new keys; newer importers
1516
+ // treat their absence as "legacy export, checkpoints not importable".
1517
+ version: '0.5.0',
1428
1518
  exported: new Date().toISOString(),
1429
1519
  session,
1430
1520
  contextItems,
1431
1521
  fileCache,
1432
1522
  checkpoints,
1523
+ checkpointItems,
1524
+ checkpointFiles,
1433
1525
  metadata: {
1434
1526
  itemCount: contextItems.length,
1435
1527
  fileCount: fileCache.length,
1436
1528
  checkpointCount: checkpoints.length,
1437
- totalSize: JSON.stringify({ contextItems, fileCache, checkpoints }).length,
1529
+ totalSize: JSON.stringify({
1530
+ contextItems,
1531
+ fileCache,
1532
+ checkpoints,
1533
+ checkpointItems,
1534
+ checkpointFiles,
1535
+ }).length,
1438
1536
  },
1439
1537
  };
1440
1538
  if (format === 'json') {
@@ -1552,10 +1650,28 @@ Files: ${stats.files}`) + sizeWarning,
1552
1650
  // fall back to creating a new session (and report that honestly).
1553
1651
  const merged = merge && !!currentSessionId;
1554
1652
  const fileCacheEntries = Array.isArray(importData.fileCache) ? importData.fileCache : [];
1653
+ const checkpointEntries = Array.isArray(importData.checkpoints) ? importData.checkpoints : [];
1654
+ const checkpointItemEntries = Array.isArray(importData.checkpointItems)
1655
+ ? importData.checkpointItems
1656
+ : [];
1657
+ const checkpointFileEntries = Array.isArray(importData.checkpointFiles)
1658
+ ? importData.checkpointFiles
1659
+ : [];
1660
+ // Presence of the checkpointItems key marks a 0.5.0+ export that carries
1661
+ // the join rows needed to restore checkpoints faithfully. Older exports
1662
+ // lack it, so their checkpoints are reported as not imported.
1663
+ const checkpointFormatSupported = Array.isArray(importData.checkpointItems);
1555
1664
  // Bound the work before opening the (synchronous, event-loop-blocking)
1556
- // transaction so a huge row count cannot stall the server.
1665
+ // transaction so a huge row count cannot stall the server. The cap is
1666
+ // per-array on purpose: checkpoint_items legitimately scales as
1667
+ // (checkpoints × items), so it routinely exceeds the item count, and an
1668
+ // aggregate sum cap would reject valid many-checkpoint sessions. The
1669
+ // 50 MB MAX_IMPORT_BYTES cap bounds the total payload independently.
1557
1670
  if (importData.contextItems.length > MAX_IMPORT_ITEMS ||
1558
- fileCacheEntries.length > MAX_IMPORT_ITEMS) {
1671
+ fileCacheEntries.length > MAX_IMPORT_ITEMS ||
1672
+ checkpointEntries.length > MAX_IMPORT_ITEMS ||
1673
+ checkpointItemEntries.length > MAX_IMPORT_ITEMS ||
1674
+ checkpointFileEntries.length > MAX_IMPORT_ITEMS) {
1559
1675
  return {
1560
1676
  content: [
1561
1677
  {
@@ -1586,14 +1702,37 @@ Files: ${stats.files}`) + sizeWarning,
1586
1702
  VALUES (?, ?, ?, ?, ?, ?)
1587
1703
  `).run(targetSessionId, `Imported: ${importedSession.name}`, `Imported from ${safePath} on ${new Date().toISOString()}`, typeof importedSession.branch === 'string' ? importedSession.branch : null, null, new Date().toISOString());
1588
1704
  }
1589
- // Import context items. Restore the channel/is_private/metadata columns
1590
- // too so a round trip is faithful, not lossy.
1591
- const itemStmt = db.prepare(`
1592
- INSERT OR REPLACE INTO context_items (id, session_id, key, value, category, priority, size, metadata, is_private, channel, created_at, updated_at)
1705
+ // Import context items, restoring channel/is_private/metadata so the
1706
+ // round trip is faithful. On a key collision we UPDATE the existing row
1707
+ // in place (preserving its id) rather than INSERT OR REPLACE: REPLACE
1708
+ // would delete the row and ON DELETE CASCADE would silently strip a
1709
+ // pre-existing checkpoint's link to it. Preserving the id keeps links.
1710
+ const insertItemStmt = db.prepare(`
1711
+ INSERT INTO context_items (id, session_id, key, value, category, priority, size, metadata, is_private, channel, created_at, updated_at)
1593
1712
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
1594
1713
  `);
1595
- let itemCount = 0;
1714
+ const findItemStmt = db.prepare('SELECT id FROM context_items WHERE session_id = ? AND key = ?');
1715
+ const updateItemStmt = db.prepare(`
1716
+ UPDATE context_items
1717
+ SET value = ?, category = ?, priority = ?, size = ?, metadata = ?, is_private = ?, channel = ?, updated_at = CURRENT_TIMESTAMP
1718
+ WHERE id = ?
1719
+ `);
1720
+ // Map each exported row's id to the new id we generate, so checkpoint
1721
+ // join rows can be rewired onto the freshly-imported items/files.
1722
+ const itemIdMap = new Map();
1723
+ const fileIdMap = new Map();
1724
+ // De-duplicate by the natural key (session_id is implied) BEFORE
1725
+ // inserting. context_items has UNIQUE(session_id, key), so two exported
1726
+ // items sharing a key would otherwise INSERT OR REPLACE-collapse — the
1727
+ // first row (still in itemIdMap) would be deleted, leaving the map
1728
+ // pointing at a row that no longer exists and making a later
1729
+ // checkpoint_items insert throw an FK error that aborts the whole
1730
+ // import. Keeping the last occurrence matches INSERT OR REPLACE
1731
+ // semantics; every exported id for that key is mapped to the survivor.
1596
1732
  let skippedItems = 0;
1733
+ let validItems = 0;
1734
+ const itemsByKey = new Map();
1735
+ const itemOldIdsByKey = new Map();
1597
1736
  for (const item of importData.contextItems) {
1598
1737
  // Skip entries that are not well-formed items rather than letting a
1599
1738
  // bad row abort the whole transaction with a SQLite binding error.
@@ -1604,22 +1743,66 @@ Files: ${stats.files}`) + sizeWarning,
1604
1743
  skippedItems++;
1605
1744
  continue;
1606
1745
  }
1607
- itemStmt.run((0, uuid_1.v4)(), targetSessionId, item.key, item.value, typeof item.category === 'string' ? item.category : null, typeof item.priority === 'string' ? item.priority : null,
1746
+ validItems++;
1747
+ itemsByKey.set(item.key, item);
1748
+ const oldIds = itemOldIdsByKey.get(item.key) ?? [];
1749
+ if (typeof item.id === 'string') {
1750
+ oldIds.push(item.id);
1751
+ }
1752
+ itemOldIdsByKey.set(item.key, oldIds);
1753
+ }
1754
+ // Valid rows that collapsed onto an earlier duplicate key.
1755
+ const collapsedItems = validItems - itemsByKey.size;
1756
+ let itemCount = 0;
1757
+ for (const [key, item] of itemsByKey) {
1758
+ const value = item.value;
1759
+ const category = typeof item.category === 'string' ? item.category : null;
1760
+ const priority = typeof item.priority === 'string' ? item.priority : null;
1608
1761
  // Use the stored size only when it is a valid non-negative number;
1609
1762
  // `|| calculateSize(...)` would wrongly recompute a legitimate 0.
1610
- typeof item.size === 'number' && item.size >= 0
1611
- ? item.size
1612
- : calculateSize(item.value), typeof item.metadata === 'string' ? item.metadata : null, typeof item.is_private === 'number' ? item.is_private : 0, typeof item.channel === 'string' ? item.channel : 'general', typeof item.created_at === 'string' ? item.created_at : new Date().toISOString());
1763
+ const size = typeof item.size === 'number' && item.size >= 0 ? item.size : calculateSize(value);
1764
+ const metadata = typeof item.metadata === 'string' ? item.metadata : null;
1765
+ const isPrivate = typeof item.is_private === 'number' ? item.is_private : 0;
1766
+ const channel = typeof item.channel === 'string' ? item.channel : 'general';
1767
+ // In merge mode, reuse the existing row id on a key collision so its
1768
+ // existing checkpoint links are preserved (no REPLACE-cascade).
1769
+ const existing = merged
1770
+ ? findItemStmt.get(targetSessionId, key)
1771
+ : undefined;
1772
+ let targetItemId;
1773
+ if (existing) {
1774
+ targetItemId = existing.id;
1775
+ updateItemStmt.run(value, category, priority, size, metadata, isPrivate, channel, targetItemId);
1776
+ }
1777
+ else {
1778
+ targetItemId = (0, uuid_1.v4)();
1779
+ insertItemStmt.run(targetItemId, targetSessionId, key, value, category, priority, size, metadata, isPrivate, channel, typeof item.created_at === 'string' ? item.created_at : new Date().toISOString());
1780
+ }
1781
+ for (const oldId of itemOldIdsByKey.get(key) ?? []) {
1782
+ itemIdMap.set(oldId, targetItemId);
1783
+ }
1613
1784
  itemCount++;
1614
1785
  }
1615
1786
  // Import file cache. content is a nullable column, so null is valid and
1616
1787
  // must NOT be treated as a malformed row (that would drop legit rows).
1617
- const fileStmt = db.prepare(`
1618
- INSERT OR REPLACE INTO file_cache (id, session_id, file_path, content, hash, last_read)
1619
- VALUES (?, ?, ?, ?, ?, ?)
1788
+ // size is restored too so the round trip is faithful. Like context
1789
+ // items, file_cache has UNIQUE(session_id, file_path); de-duplicate by
1790
+ // file_path and UPDATE-in-place on collision (preserving the id, hence
1791
+ // any pre-existing checkpoint_files link) rather than INSERT OR REPLACE.
1792
+ const insertFileStmt = db.prepare(`
1793
+ INSERT INTO file_cache (id, session_id, file_path, content, hash, size, last_read)
1794
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1795
+ `);
1796
+ const findFileStmt = db.prepare('SELECT id FROM file_cache WHERE session_id = ? AND file_path = ?');
1797
+ const updateFileStmt = db.prepare(`
1798
+ UPDATE file_cache
1799
+ SET content = ?, hash = ?, size = ?, last_read = ?, updated_at = CURRENT_TIMESTAMP
1800
+ WHERE id = ?
1620
1801
  `);
1621
- let fileCount = 0;
1622
1802
  let skippedFiles = 0;
1803
+ let validFiles = 0;
1804
+ const filesByPath = new Map();
1805
+ const fileOldIdsByPath = new Map();
1623
1806
  for (const file of fileCacheEntries) {
1624
1807
  if (typeof file !== 'object' ||
1625
1808
  file === null ||
@@ -1628,10 +1811,66 @@ Files: ${stats.files}`) + sizeWarning,
1628
1811
  skippedFiles++;
1629
1812
  continue;
1630
1813
  }
1631
- fileStmt.run((0, uuid_1.v4)(), targetSessionId, file.file_path, file.content ?? null, typeof file.hash === 'string' ? file.hash : null, typeof file.last_read === 'string' ? file.last_read : null);
1814
+ validFiles++;
1815
+ filesByPath.set(file.file_path, file);
1816
+ const oldIds = fileOldIdsByPath.get(file.file_path) ?? [];
1817
+ if (typeof file.id === 'string') {
1818
+ oldIds.push(file.id);
1819
+ }
1820
+ fileOldIdsByPath.set(file.file_path, oldIds);
1821
+ }
1822
+ const collapsedFiles = validFiles - filesByPath.size;
1823
+ let fileCount = 0;
1824
+ for (const [filePath, file] of filesByPath) {
1825
+ const content = file.content ?? null;
1826
+ const hash = typeof file.hash === 'string' ? file.hash : null;
1827
+ const size = typeof file.size === 'number' && file.size >= 0
1828
+ ? file.size
1829
+ : typeof content === 'string'
1830
+ ? calculateSize(content)
1831
+ : 0;
1832
+ const lastRead = typeof file.last_read === 'string' ? file.last_read : null;
1833
+ const existing = merged
1834
+ ? findFileStmt.get(targetSessionId, filePath)
1835
+ : undefined;
1836
+ let targetFileId;
1837
+ if (existing) {
1838
+ targetFileId = existing.id;
1839
+ updateFileStmt.run(content, hash, size, lastRead, targetFileId);
1840
+ }
1841
+ else {
1842
+ targetFileId = (0, uuid_1.v4)();
1843
+ insertFileStmt.run(targetFileId, targetSessionId, filePath, content, hash, size, lastRead);
1844
+ }
1845
+ for (const oldId of fileOldIdsByPath.get(filePath) ?? []) {
1846
+ fileIdMap.set(oldId, targetFileId);
1847
+ }
1632
1848
  fileCount++;
1633
1849
  }
1634
- return { targetSessionId, createdNew, itemCount, fileCount, skippedItems, skippedFiles };
1850
+ // Restore checkpoints + their links (0.5.0+ exports only), within this
1851
+ // same transaction. Extracted to a helper to keep the handler readable.
1852
+ const cp = checkpointFormatSupported
1853
+ ? restoreImportedCheckpoints(db, targetSessionId, checkpointEntries, checkpointItemEntries, checkpointFileEntries, itemIdMap, fileIdMap)
1854
+ : {
1855
+ checkpointCount: 0,
1856
+ skippedCheckpoints: 0,
1857
+ checkpointLinkCount: 0,
1858
+ droppedCheckpointLinks: 0,
1859
+ };
1860
+ return {
1861
+ targetSessionId,
1862
+ createdNew,
1863
+ itemCount,
1864
+ fileCount,
1865
+ skippedItems,
1866
+ skippedFiles,
1867
+ checkpointCount: cp.checkpointCount,
1868
+ skippedCheckpoints: cp.skippedCheckpoints,
1869
+ checkpointLinkCount: cp.checkpointLinkCount,
1870
+ droppedCheckpointLinks: cp.droppedCheckpointLinks,
1871
+ collapsedItems,
1872
+ collapsedFiles,
1873
+ };
1635
1874
  });
1636
1875
  const result = runImport();
1637
1876
  // Publish the new current session only after a successful commit.
@@ -1641,17 +1880,25 @@ Files: ${stats.files}`) + sizeWarning,
1641
1880
  const lines = [
1642
1881
  'Import successful!',
1643
1882
  `Session: ${result.targetSessionId.substring(0, 8)}`,
1644
- `Context items: ${result.itemCount}${result.skippedItems ? ` (skipped ${result.skippedItems} malformed)` : ''}`,
1645
- `Files: ${result.fileCount}${result.skippedFiles ? ` (skipped ${result.skippedFiles} malformed)` : ''}`,
1883
+ `Context items: ${result.itemCount}${result.skippedItems ? ` (skipped ${result.skippedItems} malformed)` : ''}${result.collapsedItems ? ` (collapsed ${result.collapsedItems} duplicate-key)` : ''}`,
1884
+ `Files: ${result.fileCount}${result.skippedFiles ? ` (skipped ${result.skippedFiles} malformed)` : ''}${result.collapsedFiles ? ` (collapsed ${result.collapsedFiles} duplicate-path)` : ''}`,
1646
1885
  `Mode: ${result.createdNew ? 'New session' : 'Merged'}`,
1647
1886
  ];
1648
- // Checkpoints are present in exports but not restored by import; say so
1649
- // explicitly instead of silently dropping them.
1650
- const checkpointCount = Array.isArray(importData.checkpoints)
1651
- ? importData.checkpoints.length
1652
- : 0;
1653
- if (checkpointCount > 0) {
1654
- lines.push(`Checkpoints in file: ${checkpointCount} (not imported)`);
1887
+ // Report checkpoints: restored for 0.5.0+ exports, or flagged as not
1888
+ // imported for legacy exports that lack the join rows.
1889
+ if (checkpointFormatSupported) {
1890
+ if (result.checkpointCount > 0 || checkpointEntries.length > 0) {
1891
+ const skipNote = result.skippedCheckpoints
1892
+ ? ` (skipped ${result.skippedCheckpoints} malformed)`
1893
+ : '';
1894
+ const dropNote = result.droppedCheckpointLinks
1895
+ ? ` (dropped ${result.droppedCheckpointLinks} dangling)`
1896
+ : '';
1897
+ lines.push(`Checkpoints: ${result.checkpointCount}${skipNote}, links restored: ${result.checkpointLinkCount}${dropNote}`);
1898
+ }
1899
+ }
1900
+ else if (checkpointEntries.length > 0) {
1901
+ lines.push(`Checkpoints in file: ${checkpointEntries.length} (not imported — this export predates checkpoint support and does not carry the item/file links)`);
1655
1902
  }
1656
1903
  // Warn when the caller asked to merge but there was no active session.
1657
1904
  if (merge && !merged) {
@@ -3856,7 +4103,8 @@ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => {
3856
4103
  // Phase 3: Export/Import
3857
4104
  {
3858
4105
  name: 'context_export',
3859
- description: 'Export session data for backup or sharing. JSON exports are written to the ' +
4106
+ description: 'Export session data (context items, cached files, and checkpoints with their ' +
4107
+ 'links) for backup or sharing. JSON exports are written to the ' +
3860
4108
  "server's exports directory (<DATA_DIR>/exports, overridable via MEMORY_KEEPER_EXPORT_DIR); " +
3861
4109
  'the response includes the full path, which can be passed back to context_import.',
3862
4110
  inputSchema: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-memory-keeper",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "MCP server for persistent context management in AI coding assistants",
5
5
  "main": "dist/index.js",
6
6
  "bin": {