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 +13 -0
- package/dist/__tests__/e2e/checkpoint-round-trip.test.js +303 -0
- package/dist/index.js +277 -29
- package/package.json +1 -1
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
|
-
|
|
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({
|
|
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
|
|
1590
|
-
//
|
|
1591
|
-
|
|
1592
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1612
|
-
|
|
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
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1649
|
-
//
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
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
|
|
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: {
|