mcp-memory-keeper 0.12.2 → 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 +33 -0
- package/README.md +16 -2
- package/dist/__tests__/e2e/checkpoint-round-trip.test.js +303 -0
- package/dist/__tests__/e2e/import-path-confinement.test.js +329 -0
- package/dist/__tests__/e2e/issue33-reproduce.test.js +234 -0
- package/dist/index.js +515 -48
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -69,6 +69,156 @@ catch (err) {
|
|
|
69
69
|
`Set DATA_DIR to a writable location or create the directory manually.`);
|
|
70
70
|
process.exit(1);
|
|
71
71
|
}
|
|
72
|
+
// Server-owned directory for session exports/imports. Both context_export
|
|
73
|
+
// (writes) and context_import (reads) are confined to this directory so that
|
|
74
|
+
// context_import can never be steered at an arbitrary file on disk. Override
|
|
75
|
+
// with MEMORY_KEEPER_EXPORT_DIR; defaults to a subdirectory of the data dir.
|
|
76
|
+
const exportsDir = process.env.MEMORY_KEEPER_EXPORT_DIR
|
|
77
|
+
? path.resolve(process.env.MEMORY_KEEPER_EXPORT_DIR)
|
|
78
|
+
: path.join(dataDir, 'exports');
|
|
79
|
+
try {
|
|
80
|
+
fs.mkdirSync(exportsDir, { recursive: true });
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
console.error(`[memory-keeper] FATAL: Cannot create exports directory "${exportsDir}": ${err.message}\n` +
|
|
84
|
+
`Set MEMORY_KEEPER_EXPORT_DIR to a writable location or create the directory manually.`);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
// Resolve symlinks once so confinement checks compare real paths on both sides.
|
|
88
|
+
let exportsDirReal;
|
|
89
|
+
try {
|
|
90
|
+
exportsDirReal = fs.realpathSync(exportsDir);
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
console.error(`[memory-keeper] FATAL: Cannot resolve exports directory "${exportsDir}": ${err.message}\n` +
|
|
94
|
+
`Set MEMORY_KEEPER_EXPORT_DIR to a readable location or create the directory manually.`);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
// Upper bound on the size of a file context_import will read into memory. The
|
|
98
|
+
// exports directory is server-owned, but context_export can write arbitrarily
|
|
99
|
+
// large session dumps there, so cap the read to avoid memory exhaustion.
|
|
100
|
+
const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
|
|
101
|
+
// Upper bound on how many context items / file-cache entries a single import
|
|
102
|
+
// will process. better-sqlite3 transactions run synchronously and block the
|
|
103
|
+
// event loop, so an export crafted with millions of tiny rows (still under the
|
|
104
|
+
// byte cap) must not be allowed to stall the server.
|
|
105
|
+
const MAX_IMPORT_ITEMS = 100_000;
|
|
106
|
+
/**
|
|
107
|
+
* Resolve a caller-supplied import path and confine it to the exports
|
|
108
|
+
* directory. Relative paths are resolved against the exports directory;
|
|
109
|
+
* absolute paths are accepted only if they resolve (after following symlinks)
|
|
110
|
+
* to a location inside the exports directory.
|
|
111
|
+
*
|
|
112
|
+
* Throws an Error whose message is safe to surface to the caller — it never
|
|
113
|
+
* contains any bytes read from the target file.
|
|
114
|
+
*/
|
|
115
|
+
function resolveConfinedImportPath(filePath) {
|
|
116
|
+
if (typeof filePath !== 'string' || filePath.length === 0) {
|
|
117
|
+
throw new Error('filePath is required and must be a non-empty string');
|
|
118
|
+
}
|
|
119
|
+
// Resolve relative paths against the exports directory; leave absolute as-is.
|
|
120
|
+
const candidate = path.resolve(exportsDirReal, filePath);
|
|
121
|
+
// realpathSync follows symlinks and requires the file to exist; an attacker
|
|
122
|
+
// cannot use a symlink inside the exports dir to escape it. statSync on the
|
|
123
|
+
// resolved path then tells us it is a regular file of acceptable size.
|
|
124
|
+
let resolved;
|
|
125
|
+
let stats;
|
|
126
|
+
try {
|
|
127
|
+
resolved = fs.realpathSync(candidate);
|
|
128
|
+
stats = fs.statSync(resolved);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
throw new Error('import file not found in the exports directory');
|
|
132
|
+
}
|
|
133
|
+
// Use the SAME message as the not-found case so a file that exists outside
|
|
134
|
+
// the exports directory is indistinguishable from a missing one (no
|
|
135
|
+
// existence oracle), and never echo the resolved absolute path.
|
|
136
|
+
const withinExports = resolved === exportsDirReal || resolved.startsWith(exportsDirReal + path.sep);
|
|
137
|
+
if (!withinExports) {
|
|
138
|
+
throw new Error('import file not found in the exports directory');
|
|
139
|
+
}
|
|
140
|
+
if (!stats.isFile()) {
|
|
141
|
+
throw new Error('import path must be a regular file');
|
|
142
|
+
}
|
|
143
|
+
if (stats.size > MAX_IMPORT_BYTES) {
|
|
144
|
+
throw new Error(`import file exceeds the maximum allowed size (${MAX_IMPORT_BYTES} bytes)`);
|
|
145
|
+
}
|
|
146
|
+
return resolved;
|
|
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
|
+
}
|
|
72
222
|
// Warn users whose legacy DB is sitting in CWD
|
|
73
223
|
const legacyDb = path.join(process.cwd(), 'context.db');
|
|
74
224
|
if (process.cwd() !== dataDir && fs.existsSync(legacyDb)) {
|
|
@@ -327,10 +477,23 @@ function createSummary(items, options) {
|
|
|
327
477
|
}
|
|
328
478
|
return summary;
|
|
329
479
|
}
|
|
480
|
+
// Read the package version at runtime so the server-reported version can never
|
|
481
|
+
// drift from package.json. The compiled entry (dist/index.js) sits one level
|
|
482
|
+
// below the package root, so package.json is at ../package.json.
|
|
483
|
+
function readPackageVersion() {
|
|
484
|
+
try {
|
|
485
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
486
|
+
return typeof pkg.version === 'string' ? pkg.version : '0.0.0';
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
return '0.0.0';
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
const SERVER_VERSION = readPackageVersion();
|
|
330
493
|
// Create MCP server
|
|
331
494
|
const server = new index_js_1.Server({
|
|
332
495
|
name: 'memory-keeper',
|
|
333
|
-
version:
|
|
496
|
+
version: SERVER_VERSION,
|
|
334
497
|
}, {
|
|
335
498
|
capabilities: {
|
|
336
499
|
tools: {},
|
|
@@ -1320,6 +1483,19 @@ Checkpoint: ${autoSave ? `git-commit-${new Date().toISOString()}` : 'None'}`,
|
|
|
1320
1483
|
const checkpoints = db
|
|
1321
1484
|
.prepare('SELECT * FROM checkpoints WHERE session_id = ?')
|
|
1322
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);
|
|
1323
1499
|
// Check if session is empty
|
|
1324
1500
|
const isEmpty = contextItems.length === 0 && fileCache.length === 0 && checkpoints.length === 0;
|
|
1325
1501
|
if (isEmpty && !confirmEmpty) {
|
|
@@ -1335,21 +1511,32 @@ Checkpoint: ${autoSave ? `git-commit-${new Date().toISOString()}` : 'None'}`,
|
|
|
1335
1511
|
};
|
|
1336
1512
|
}
|
|
1337
1513
|
const exportData = {
|
|
1338
|
-
|
|
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',
|
|
1339
1518
|
exported: new Date().toISOString(),
|
|
1340
1519
|
session,
|
|
1341
1520
|
contextItems,
|
|
1342
1521
|
fileCache,
|
|
1343
1522
|
checkpoints,
|
|
1523
|
+
checkpointItems,
|
|
1524
|
+
checkpointFiles,
|
|
1344
1525
|
metadata: {
|
|
1345
1526
|
itemCount: contextItems.length,
|
|
1346
1527
|
fileCount: fileCache.length,
|
|
1347
1528
|
checkpointCount: checkpoints.length,
|
|
1348
|
-
totalSize: JSON.stringify({
|
|
1529
|
+
totalSize: JSON.stringify({
|
|
1530
|
+
contextItems,
|
|
1531
|
+
fileCache,
|
|
1532
|
+
checkpoints,
|
|
1533
|
+
checkpointItems,
|
|
1534
|
+
checkpointFiles,
|
|
1535
|
+
}).length,
|
|
1349
1536
|
},
|
|
1350
1537
|
};
|
|
1351
1538
|
if (format === 'json') {
|
|
1352
|
-
const exportPath = path.join(
|
|
1539
|
+
const exportPath = path.join(exportsDir, `memory-keeper-export-${targetSessionId.substring(0, 8)}.json`);
|
|
1353
1540
|
// Check write permissions
|
|
1354
1541
|
try {
|
|
1355
1542
|
fs.writeFileSync(exportPath, JSON.stringify(exportData, null, 2));
|
|
@@ -1366,11 +1553,16 @@ Checkpoint: ${autoSave ? `git-commit-${new Date().toISOString()}` : 'None'}`,
|
|
|
1366
1553
|
checkpoints: checkpoints.length,
|
|
1367
1554
|
size: fs.statSync(exportPath).size,
|
|
1368
1555
|
};
|
|
1556
|
+
// context_import caps reads at MAX_IMPORT_BYTES; warn if this export is
|
|
1557
|
+
// too large to be re-imported, so the round trip never breaks silently.
|
|
1558
|
+
const sizeWarning = stats.size > MAX_IMPORT_BYTES
|
|
1559
|
+
? `\n⚠️ This export is ${(stats.size / (1024 * 1024)).toFixed(1)} MB and exceeds the ${MAX_IMPORT_BYTES / (1024 * 1024)} MB import limit; context_import will reject it.`
|
|
1560
|
+
: '';
|
|
1369
1561
|
return {
|
|
1370
1562
|
content: [
|
|
1371
1563
|
{
|
|
1372
1564
|
type: 'text',
|
|
1373
|
-
text: includeStats
|
|
1565
|
+
text: (includeStats
|
|
1374
1566
|
? `✅ Successfully exported session "${session.name}" to: ${exportPath}
|
|
1375
1567
|
|
|
1376
1568
|
📊 Export Statistics:
|
|
@@ -1382,7 +1574,7 @@ Checkpoint: ${autoSave ? `git-commit-${new Date().toISOString()}` : 'None'}`,
|
|
|
1382
1574
|
Session ID: ${targetSessionId}`
|
|
1383
1575
|
: `Exported session to: ${exportPath}
|
|
1384
1576
|
Items: ${stats.items}
|
|
1385
|
-
Files: ${stats.files}
|
|
1577
|
+
Files: ${stats.files}`) + sizeWarning,
|
|
1386
1578
|
},
|
|
1387
1579
|
],
|
|
1388
1580
|
exportPath,
|
|
@@ -1406,61 +1598,328 @@ Files: ${stats.files}`,
|
|
|
1406
1598
|
}
|
|
1407
1599
|
case 'context_import': {
|
|
1408
1600
|
const { filePath, merge = false } = args;
|
|
1601
|
+
// Confine the path to the server-owned exports directory BEFORE touching
|
|
1602
|
+
// the filesystem. A failure here is a path/permission problem, never file
|
|
1603
|
+
// content, so the message is safe to return verbatim.
|
|
1604
|
+
let safePath;
|
|
1409
1605
|
try {
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
}
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
// Import context items
|
|
1426
|
-
const itemStmt = db.prepare(`
|
|
1427
|
-
INSERT OR REPLACE INTO context_items (id, session_id, key, value, category, priority, size, created_at, updated_at)
|
|
1428
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
1429
|
-
`);
|
|
1430
|
-
let itemCount = 0;
|
|
1431
|
-
for (const item of importData.contextItems) {
|
|
1432
|
-
itemStmt.run((0, uuid_1.v4)(), targetSessionId, item.key, item.value, item.category, item.priority, item.size || calculateSize(item.value), item.created_at);
|
|
1433
|
-
itemCount++;
|
|
1606
|
+
safePath = resolveConfinedImportPath(filePath);
|
|
1607
|
+
}
|
|
1608
|
+
catch (error) {
|
|
1609
|
+
return {
|
|
1610
|
+
content: [{ type: 'text', text: `Import failed: ${error.message}` }],
|
|
1611
|
+
};
|
|
1612
|
+
}
|
|
1613
|
+
// Read and parse separately so a JSON.parse error can never echo file
|
|
1614
|
+
// bytes back to the caller (V8 includes the offending input in its
|
|
1615
|
+
// SyntaxError message).
|
|
1616
|
+
let importData;
|
|
1617
|
+
try {
|
|
1618
|
+
const raw = fs.readFileSync(safePath, 'utf8');
|
|
1619
|
+
try {
|
|
1620
|
+
importData = JSON.parse(raw);
|
|
1434
1621
|
}
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
`);
|
|
1440
|
-
let fileCount = 0;
|
|
1441
|
-
for (const file of importData.fileCache || []) {
|
|
1442
|
-
fileStmt.run((0, uuid_1.v4)(), targetSessionId, file.file_path, file.content, file.hash, file.last_read);
|
|
1443
|
-
fileCount++;
|
|
1622
|
+
catch {
|
|
1623
|
+
return {
|
|
1624
|
+
content: [{ type: 'text', text: 'Import failed: file is not valid JSON export data' }],
|
|
1625
|
+
};
|
|
1444
1626
|
}
|
|
1627
|
+
}
|
|
1628
|
+
catch {
|
|
1629
|
+
return {
|
|
1630
|
+
content: [{ type: 'text', text: 'Import failed: could not read import file' }],
|
|
1631
|
+
};
|
|
1632
|
+
}
|
|
1633
|
+
// Validate the import has the expected shape before using it.
|
|
1634
|
+
if (typeof importData !== 'object' ||
|
|
1635
|
+
importData === null ||
|
|
1636
|
+
typeof importData.session !== 'object' ||
|
|
1637
|
+
importData.session === null ||
|
|
1638
|
+
typeof importData.session.name !== 'string' ||
|
|
1639
|
+
!Array.isArray(importData.contextItems)) {
|
|
1445
1640
|
return {
|
|
1446
1641
|
content: [
|
|
1447
1642
|
{
|
|
1448
1643
|
type: 'text',
|
|
1449
|
-
text:
|
|
1450
|
-
Session: ${targetSessionId.substring(0, 8)}
|
|
1451
|
-
Context items: ${itemCount}
|
|
1452
|
-
Files: ${fileCount}
|
|
1453
|
-
Mode: ${merge ? 'Merged' : 'New session'}`,
|
|
1644
|
+
text: 'Import failed: file is not a valid memory-keeper export (missing session or contextItems)',
|
|
1454
1645
|
},
|
|
1455
1646
|
],
|
|
1456
1647
|
};
|
|
1457
1648
|
}
|
|
1649
|
+
// Merge only when there is actually a session to merge into; otherwise
|
|
1650
|
+
// fall back to creating a new session (and report that honestly).
|
|
1651
|
+
const merged = merge && !!currentSessionId;
|
|
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);
|
|
1664
|
+
// Bound the work before opening the (synchronous, event-loop-blocking)
|
|
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.
|
|
1670
|
+
if (importData.contextItems.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) {
|
|
1675
|
+
return {
|
|
1676
|
+
content: [
|
|
1677
|
+
{
|
|
1678
|
+
type: 'text',
|
|
1679
|
+
text: `Import failed: file exceeds the maximum allowed entry count (${MAX_IMPORT_ITEMS})`,
|
|
1680
|
+
},
|
|
1681
|
+
],
|
|
1682
|
+
};
|
|
1683
|
+
}
|
|
1684
|
+
try {
|
|
1685
|
+
// Apply the whole import atomically so a malformed row cannot leave an
|
|
1686
|
+
// orphaned session or a partially-populated one behind. We do NOT mutate
|
|
1687
|
+
// the module-level currentSessionId inside the transaction: better-sqlite3
|
|
1688
|
+
// rolls back the database on error but cannot revert a JS assignment, so a
|
|
1689
|
+
// post-assignment failure would leave currentSessionId dangling. Instead we
|
|
1690
|
+
// return the new id and publish it only after the transaction commits.
|
|
1691
|
+
const runImport = db.transaction(() => {
|
|
1692
|
+
let targetSessionId;
|
|
1693
|
+
const createdNew = !merged;
|
|
1694
|
+
if (merged) {
|
|
1695
|
+
targetSessionId = currentSessionId;
|
|
1696
|
+
}
|
|
1697
|
+
else {
|
|
1698
|
+
targetSessionId = (0, uuid_1.v4)();
|
|
1699
|
+
const importedSession = importData.session;
|
|
1700
|
+
db.prepare(`
|
|
1701
|
+
INSERT INTO sessions (id, name, description, branch, working_directory, created_at)
|
|
1702
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
1703
|
+
`).run(targetSessionId, `Imported: ${importedSession.name}`, `Imported from ${safePath} on ${new Date().toISOString()}`, typeof importedSession.branch === 'string' ? importedSession.branch : null, null, new Date().toISOString());
|
|
1704
|
+
}
|
|
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)
|
|
1712
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
1713
|
+
`);
|
|
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.
|
|
1732
|
+
let skippedItems = 0;
|
|
1733
|
+
let validItems = 0;
|
|
1734
|
+
const itemsByKey = new Map();
|
|
1735
|
+
const itemOldIdsByKey = new Map();
|
|
1736
|
+
for (const item of importData.contextItems) {
|
|
1737
|
+
// Skip entries that are not well-formed items rather than letting a
|
|
1738
|
+
// bad row abort the whole transaction with a SQLite binding error.
|
|
1739
|
+
if (typeof item !== 'object' ||
|
|
1740
|
+
item === null ||
|
|
1741
|
+
typeof item.key !== 'string' ||
|
|
1742
|
+
typeof item.value !== 'string') {
|
|
1743
|
+
skippedItems++;
|
|
1744
|
+
continue;
|
|
1745
|
+
}
|
|
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;
|
|
1761
|
+
// Use the stored size only when it is a valid non-negative number;
|
|
1762
|
+
// `|| calculateSize(...)` would wrongly recompute a legitimate 0.
|
|
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
|
+
}
|
|
1784
|
+
itemCount++;
|
|
1785
|
+
}
|
|
1786
|
+
// Import file cache. content is a nullable column, so null is valid and
|
|
1787
|
+
// must NOT be treated as a malformed row (that would drop legit rows).
|
|
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 = ?
|
|
1801
|
+
`);
|
|
1802
|
+
let skippedFiles = 0;
|
|
1803
|
+
let validFiles = 0;
|
|
1804
|
+
const filesByPath = new Map();
|
|
1805
|
+
const fileOldIdsByPath = new Map();
|
|
1806
|
+
for (const file of fileCacheEntries) {
|
|
1807
|
+
if (typeof file !== 'object' ||
|
|
1808
|
+
file === null ||
|
|
1809
|
+
typeof file.file_path !== 'string' ||
|
|
1810
|
+
(file.content !== null && typeof file.content !== 'string')) {
|
|
1811
|
+
skippedFiles++;
|
|
1812
|
+
continue;
|
|
1813
|
+
}
|
|
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
|
+
}
|
|
1848
|
+
fileCount++;
|
|
1849
|
+
}
|
|
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
|
+
};
|
|
1874
|
+
});
|
|
1875
|
+
const result = runImport();
|
|
1876
|
+
// Publish the new current session only after a successful commit.
|
|
1877
|
+
if (result.createdNew) {
|
|
1878
|
+
currentSessionId = result.targetSessionId;
|
|
1879
|
+
}
|
|
1880
|
+
const lines = [
|
|
1881
|
+
'Import successful!',
|
|
1882
|
+
`Session: ${result.targetSessionId.substring(0, 8)}`,
|
|
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)` : ''}`,
|
|
1885
|
+
`Mode: ${result.createdNew ? 'New session' : 'Merged'}`,
|
|
1886
|
+
];
|
|
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)`);
|
|
1902
|
+
}
|
|
1903
|
+
// Warn when the caller asked to merge but there was no active session.
|
|
1904
|
+
if (merge && !merged) {
|
|
1905
|
+
lines.push('Note: merge requested but no active session existed; created a new session.');
|
|
1906
|
+
}
|
|
1907
|
+
if (result.itemCount === 0 && importData.contextItems.length > 0) {
|
|
1908
|
+
lines.push('Warning: no valid context items were found; nothing was imported.');
|
|
1909
|
+
}
|
|
1910
|
+
return {
|
|
1911
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
1912
|
+
};
|
|
1913
|
+
}
|
|
1458
1914
|
catch (error) {
|
|
1915
|
+
// The error here comes from the DB layer and may echo the values being
|
|
1916
|
+
// inserted; keep the caller-facing message generic and log the detail.
|
|
1917
|
+
console.error(`[memory-keeper] context_import failed: ${error?.message ?? error}`);
|
|
1459
1918
|
return {
|
|
1460
1919
|
content: [
|
|
1461
1920
|
{
|
|
1462
1921
|
type: 'text',
|
|
1463
|
-
text:
|
|
1922
|
+
text: 'Import failed: could not write imported data to the database',
|
|
1464
1923
|
},
|
|
1465
1924
|
],
|
|
1466
1925
|
};
|
|
@@ -3644,7 +4103,10 @@ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => {
|
|
|
3644
4103
|
// Phase 3: Export/Import
|
|
3645
4104
|
{
|
|
3646
4105
|
name: 'context_export',
|
|
3647
|
-
description: 'Export session data
|
|
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 ' +
|
|
4108
|
+
"server's exports directory (<DATA_DIR>/exports, overridable via MEMORY_KEEPER_EXPORT_DIR); " +
|
|
4109
|
+
'the response includes the full path, which can be passed back to context_import.',
|
|
3648
4110
|
inputSchema: {
|
|
3649
4111
|
type: 'object',
|
|
3650
4112
|
properties: {
|
|
@@ -3660,11 +4122,16 @@ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => {
|
|
|
3660
4122
|
},
|
|
3661
4123
|
{
|
|
3662
4124
|
name: 'context_import',
|
|
3663
|
-
description: 'Import previously exported session data'
|
|
4125
|
+
description: 'Import previously exported session data. For security, imports are confined to the ' +
|
|
4126
|
+
"server's exports directory (where context_export writes); arbitrary filesystem paths are rejected.",
|
|
3664
4127
|
inputSchema: {
|
|
3665
4128
|
type: 'object',
|
|
3666
4129
|
properties: {
|
|
3667
|
-
filePath: {
|
|
4130
|
+
filePath: {
|
|
4131
|
+
type: 'string',
|
|
4132
|
+
description: 'Path to an export file inside the exports directory. Relative paths resolve ' +
|
|
4133
|
+
'against that directory; absolute paths must point inside it.',
|
|
4134
|
+
},
|
|
3668
4135
|
merge: {
|
|
3669
4136
|
type: 'boolean',
|
|
3670
4137
|
description: 'Merge with current session instead of creating new',
|