opentology 0.2.7 → 0.3.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.
@@ -217,6 +217,20 @@ export function registerContext(program) {
217
217
  }
218
218
  settings.hooks = hooks;
219
219
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
220
+ // Ensure .opentology/snapshots/ is in .gitignore
221
+ const gitignorePath = join(process.cwd(), '.gitignore');
222
+ const snapshotIgnore = '.opentology/snapshots/';
223
+ if (existsSync(gitignorePath)) {
224
+ const gitignoreContent = readFileSync(gitignorePath, 'utf-8');
225
+ if (!gitignoreContent.includes(snapshotIgnore)) {
226
+ writeFileSync(gitignorePath, gitignoreContent.trimEnd() + '\n' + snapshotIgnore + '\n', 'utf-8');
227
+ console.log(pc.green(' Added .opentology/snapshots/ to .gitignore'));
228
+ }
229
+ }
230
+ else {
231
+ writeFileSync(gitignorePath, snapshotIgnore + '\n', 'utf-8');
232
+ console.log(pc.green(' Created .gitignore with .opentology/snapshots/'));
233
+ }
220
234
  console.log('');
221
235
  console.log(pc.dim('Consider adding .opentology/hooks/ to version control so team members share the hook.'));
222
236
  }
@@ -1,6 +1,7 @@
1
1
  import pc from 'picocolors';
2
2
  import { loadConfig, resolveGraphUri, saveConfig } from '../lib/config.js';
3
3
  import { createReadyAdapter } from '../lib/store-factory.js';
4
+ import { snapshotGraph } from '../lib/snapshot.js';
4
5
  export function registerDrop(program) {
5
6
  program
6
7
  .command('drop')
@@ -23,6 +24,7 @@ export function registerDrop(program) {
23
24
  }
24
25
  try {
25
26
  const adapter = await createReadyAdapter(config);
27
+ await snapshotGraph(adapter, config, graphUri);
26
28
  await adapter.dropGraph(graphUri);
27
29
  // In embedded mode, clear tracked files for this graph
28
30
  if (config.mode === 'embedded') {
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare function registerRollback(program: Command): void;
@@ -0,0 +1,75 @@
1
+ import pc from 'picocolors';
2
+ import { loadConfig, resolveGraphUri } from '../lib/config.js';
3
+ import { createReadyAdapter } from '../lib/store-factory.js';
4
+ import { listSnapshots, restoreSnapshot } from '../lib/snapshot.js';
5
+ import { createInterface } from 'node:readline';
6
+ function ask(question) {
7
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
8
+ return new Promise((resolve) => {
9
+ rl.question(question, (answer) => {
10
+ rl.close();
11
+ resolve(answer.trim().toLowerCase());
12
+ });
13
+ });
14
+ }
15
+ export function registerRollback(program) {
16
+ program
17
+ .command('rollback')
18
+ .description('List or restore graph snapshots')
19
+ .option('--list', 'List available snapshots')
20
+ .option('--to <timestamp>', 'Restore to a specific snapshot timestamp')
21
+ .option('--graph <name>', 'Target a specific named graph')
22
+ .action(async (opts) => {
23
+ let config;
24
+ try {
25
+ config = loadConfig();
26
+ }
27
+ catch (err) {
28
+ console.error(`Error: ${err.message}`);
29
+ process.exit(1);
30
+ }
31
+ const graphUri = opts.graph ? resolveGraphUri(config, opts.graph) : config.graphUri;
32
+ if (opts.list) {
33
+ const snapshots = listSnapshots(graphUri);
34
+ if (snapshots.length === 0) {
35
+ console.log(pc.dim('No snapshots found for this graph.'));
36
+ return;
37
+ }
38
+ console.log(pc.bold(`Snapshots for ${graphUri}:`));
39
+ console.log('');
40
+ for (const s of snapshots) {
41
+ const sizeKb = (s.sizeBytes / 1024).toFixed(1);
42
+ const label = s.isPreRollback ? pc.yellow(' (pre-rollback)') : '';
43
+ console.log(` ${pc.dim(s.timestamp)} ${sizeKb} KB${label}`);
44
+ }
45
+ return;
46
+ }
47
+ try {
48
+ const adapter = await createReadyAdapter(config);
49
+ if (opts.to) {
50
+ await restoreSnapshot(adapter, config, graphUri, opts.to);
51
+ console.log(pc.green(`Restored graph to snapshot: ${opts.to}`));
52
+ return;
53
+ }
54
+ // No flags: restore latest snapshot with confirmation
55
+ const snapshots = listSnapshots(graphUri).filter((s) => !s.isPreRollback);
56
+ if (snapshots.length === 0) {
57
+ console.log(pc.dim('No snapshots found for this graph.'));
58
+ return;
59
+ }
60
+ const latest = snapshots[0];
61
+ const answer = await ask(`Restore to latest snapshot ${pc.bold(latest.timestamp)}? [y/N] `);
62
+ if (answer === 'y' || answer === 'yes') {
63
+ await restoreSnapshot(adapter, config, graphUri, latest.timestamp);
64
+ console.log(pc.green(`Restored graph to snapshot: ${latest.timestamp}`));
65
+ }
66
+ else {
67
+ console.log(pc.dim('Rollback cancelled.'));
68
+ }
69
+ }
70
+ catch (err) {
71
+ console.error(pc.red(`Error: ${err.message}`));
72
+ process.exit(1);
73
+ }
74
+ });
75
+ }
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ import { registerPrefix } from './commands/prefix.js';
17
17
  import { registerContext } from './commands/context.js';
18
18
  import { registerViz } from './commands/viz.js';
19
19
  import { registerDoctor } from './commands/doctor.js';
20
+ import { registerRollback } from './commands/rollback.js';
20
21
  const program = new Command();
21
22
  program
22
23
  .name('opentology')
@@ -39,4 +40,5 @@ registerPrefix(program);
39
40
  registerContext(program);
40
41
  registerViz(program);
41
42
  registerDoctor(program);
43
+ registerRollback(program);
42
44
  program.parse(process.argv);
@@ -0,0 +1,40 @@
1
+ import type { StoreAdapter } from './store-adapter.js';
2
+ import type { OpenTologyConfig } from './config.js';
3
+ /**
4
+ * Derive a filesystem-safe slug from a graph URI.
5
+ * Reuses the same pattern as persistGraph().
6
+ */
7
+ export declare function graphSlug(graphUri: string): string;
8
+ /**
9
+ * Generate an ISO timestamp suitable for filenames.
10
+ * Format: 2026-04-05T14-30-22-123Z (colons replaced with dashes, ms included).
11
+ */
12
+ export declare function toFilenameTimestamp(date?: Date): string;
13
+ /**
14
+ * Snapshot a named graph before a destructive operation.
15
+ * No-op if not embedded mode or if the graph is empty.
16
+ * Automatically prunes old snapshots after saving.
17
+ */
18
+ export declare function snapshotGraph(adapter: StoreAdapter, config: OpenTologyConfig, graphUri: string, retention?: number): Promise<string | null>;
19
+ export interface SnapshotInfo {
20
+ timestamp: string;
21
+ path: string;
22
+ sizeBytes: number;
23
+ isPreRollback: boolean;
24
+ }
25
+ /**
26
+ * List snapshots for a graph, sorted by timestamp descending (newest first).
27
+ */
28
+ export declare function listSnapshots(graphUri: string): SnapshotInfo[];
29
+ /**
30
+ * Restore a graph to a specific snapshot.
31
+ * 1. Saves current state as {timestamp}_pre-rollback.ttl (snapshot-before-rollback)
32
+ * 2. Drops the graph and loads the snapshot
33
+ * 3. Persists the restored state to .opentology/data/
34
+ */
35
+ export declare function restoreSnapshot(adapter: StoreAdapter, config: OpenTologyConfig, graphUri: string, timestamp: string): Promise<void>;
36
+ /**
37
+ * Prune old snapshots, keeping only the most recent `keep` count.
38
+ * Pre-rollback snapshots are pruned separately (keep 2).
39
+ */
40
+ export declare function pruneSnapshots(graphUri: string, keep?: number): Promise<number>;
@@ -0,0 +1,136 @@
1
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, unlinkSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { persistGraph } from '../mcp/server.js';
4
+ const DEFAULT_RETENTION = 5;
5
+ /**
6
+ * Derive a filesystem-safe slug from a graph URI.
7
+ * Reuses the same pattern as persistGraph().
8
+ */
9
+ export function graphSlug(graphUri) {
10
+ return graphUri.replace(/[^a-zA-Z0-9-]/g, '_').replace(/_+/g, '_');
11
+ }
12
+ /**
13
+ * Generate an ISO timestamp suitable for filenames.
14
+ * Format: 2026-04-05T14-30-22-123Z (colons replaced with dashes, ms included).
15
+ */
16
+ export function toFilenameTimestamp(date = new Date()) {
17
+ return date.toISOString().replace(/:/g, '-').replace(/\./g, '-');
18
+ }
19
+ /**
20
+ * Parse a filename timestamp back to a Date.
21
+ */
22
+ function parseFilenameTimestamp(filename) {
23
+ // Remove .ttl suffix and _pre-rollback suffix
24
+ const base = filename.replace(/\.ttl$/, '').replace(/_pre-rollback$/, '');
25
+ // Reverse the filename encoding: dashes back to colons/dots
26
+ // Format: 2026-04-05T14-30-22-123Z → 2026-04-05T14:30:22.123Z
27
+ const parts = base.match(/^(\d{4}-\d{2}-\d{2}T)(\d{2})-(\d{2})-(\d{2})-(\d{3}Z)$/);
28
+ if (!parts)
29
+ return null;
30
+ const iso = `${parts[1]}${parts[2]}:${parts[3]}:${parts[4]}.${parts[5]}`;
31
+ const d = new Date(iso);
32
+ return isNaN(d.getTime()) ? null : d;
33
+ }
34
+ function snapshotDir(graphUri) {
35
+ return join(process.cwd(), '.opentology', 'snapshots', graphSlug(graphUri));
36
+ }
37
+ /**
38
+ * Snapshot a named graph before a destructive operation.
39
+ * No-op if not embedded mode or if the graph is empty.
40
+ * Automatically prunes old snapshots after saving.
41
+ */
42
+ export async function snapshotGraph(adapter, config, graphUri, retention = DEFAULT_RETENTION) {
43
+ if (config.mode !== 'embedded')
44
+ return null;
45
+ const exported = await adapter.exportGraph(graphUri);
46
+ if (!exported.trim())
47
+ return null;
48
+ const dir = snapshotDir(graphUri);
49
+ mkdirSync(dir, { recursive: true });
50
+ const timestamp = toFilenameTimestamp();
51
+ const filename = `${timestamp}.ttl`;
52
+ const filePath = join(dir, filename);
53
+ writeFileSync(filePath, exported, 'utf-8');
54
+ // Verify the file was written
55
+ if (!existsSync(filePath)) {
56
+ throw new Error(`Snapshot write failed: ${filePath}`);
57
+ }
58
+ // Prune old snapshots (create → verify → prune order)
59
+ await pruneSnapshots(graphUri, retention);
60
+ return filePath;
61
+ }
62
+ /**
63
+ * List snapshots for a graph, sorted by timestamp descending (newest first).
64
+ */
65
+ export function listSnapshots(graphUri) {
66
+ const dir = snapshotDir(graphUri);
67
+ if (!existsSync(dir))
68
+ return [];
69
+ const files = readdirSync(dir).filter((f) => f.endsWith('.ttl'));
70
+ return files
71
+ .map((f) => {
72
+ const fullPath = join(dir, f);
73
+ const stat = statSync(fullPath);
74
+ return {
75
+ timestamp: f.replace(/\.ttl$/, ''),
76
+ path: fullPath,
77
+ sizeBytes: stat.size,
78
+ isPreRollback: f.includes('_pre-rollback'),
79
+ };
80
+ })
81
+ .sort((a, b) => b.timestamp.localeCompare(a.timestamp));
82
+ }
83
+ /**
84
+ * Restore a graph to a specific snapshot.
85
+ * 1. Saves current state as {timestamp}_pre-rollback.ttl (snapshot-before-rollback)
86
+ * 2. Drops the graph and loads the snapshot
87
+ * 3. Persists the restored state to .opentology/data/
88
+ */
89
+ export async function restoreSnapshot(adapter, config, graphUri, timestamp) {
90
+ const dir = snapshotDir(graphUri);
91
+ const snapshotPath = join(dir, `${timestamp}.ttl`);
92
+ if (!existsSync(snapshotPath)) {
93
+ throw new Error(`Snapshot not found: ${timestamp}`);
94
+ }
95
+ // Step 1: Snapshot-before-rollback — save current state
96
+ const currentExport = await adapter.exportGraph(graphUri);
97
+ if (currentExport.trim()) {
98
+ mkdirSync(dir, { recursive: true });
99
+ const preRollbackTimestamp = toFilenameTimestamp();
100
+ const preRollbackPath = join(dir, `${preRollbackTimestamp}_pre-rollback.ttl`);
101
+ writeFileSync(preRollbackPath, currentExport, 'utf-8');
102
+ }
103
+ // Step 2: Drop and restore
104
+ const snapshotTurtle = readFileSync(snapshotPath, 'utf-8');
105
+ await adapter.dropGraph(graphUri);
106
+ await adapter.insertTurtle(graphUri, snapshotTurtle);
107
+ // Step 3: Persist restored state
108
+ await persistGraph(adapter, config, graphUri);
109
+ }
110
+ /**
111
+ * Prune old snapshots, keeping only the most recent `keep` count.
112
+ * Pre-rollback snapshots are pruned separately (keep 2).
113
+ */
114
+ export async function pruneSnapshots(graphUri, keep = DEFAULT_RETENTION) {
115
+ const dir = snapshotDir(graphUri);
116
+ if (!existsSync(dir))
117
+ return 0;
118
+ const files = readdirSync(dir).filter((f) => f.endsWith('.ttl'));
119
+ // Separate regular snapshots and pre-rollback snapshots
120
+ const regular = files.filter((f) => !f.includes('_pre-rollback')).sort();
121
+ const preRollback = files.filter((f) => f.includes('_pre-rollback')).sort();
122
+ let deleted = 0;
123
+ // Prune regular snapshots
124
+ while (regular.length > keep) {
125
+ const oldest = regular.shift();
126
+ unlinkSync(join(dir, oldest));
127
+ deleted++;
128
+ }
129
+ // Prune pre-rollback snapshots (keep max 2)
130
+ while (preRollback.length > 2) {
131
+ const oldest = preRollback.shift();
132
+ unlinkSync(join(dir, oldest));
133
+ deleted++;
134
+ }
135
+ return deleted;
136
+ }
@@ -1,3 +1,11 @@
1
+ import type { OpenTologyConfig } from '../lib/config.js';
2
+ import type { StoreAdapter } from '../lib/store-adapter.js';
1
3
  export declare const MAX_TRIPLES_PER_PUSH = 100;
2
4
  export declare function assertTripleLimit(tripleCount: number): void;
5
+ /**
6
+ * Persist a named graph to a .ttl file in embedded mode.
7
+ * Exports the full graph, writes to .opentology/data/{slug}.ttl, and tracks
8
+ * the file in config — mirroring CLI push behavior.
9
+ */
10
+ export declare function persistGraph(adapter: StoreAdapter, config: OpenTologyConfig, graphUri: string): Promise<void>;
3
11
  export declare function startMcpServer(): Promise<void>;
@@ -1,11 +1,12 @@
1
1
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { ListToolsRequestSchema, CallToolRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
4
- import { loadConfig, saveConfig, configExists, resolveGraphUri } from '../lib/config.js';
4
+ import { loadConfig, saveConfig, configExists, resolveGraphUri, addTrackedFile } from '../lib/config.js';
5
5
  import { deepScan } from '../lib/deep-scanner.js';
6
6
  import { pushSymbolTriples } from '../lib/deep-scan-triples.js';
7
7
  import { createReadyAdapter } from '../lib/store-factory.js';
8
8
  import { normalizeModuleUri } from '../lib/module-uri.js';
9
+ import { snapshotGraph, listSnapshots, restoreSnapshot } from '../lib/snapshot.js';
9
10
  import { hasGraphScope, autoScopeQuery, getInferenceGraphUri } from '../lib/sparql-utils.js';
10
11
  import { validateTurtle } from '../lib/validator.js';
11
12
  import { discoverShapes, validateWithShacl, hasShapes } from '../lib/shacl.js';
@@ -41,6 +42,26 @@ function resolveConfig(params) {
41
42
  throw new Error('No config found. Run opentology init first.');
42
43
  }
43
44
  }
45
+ /**
46
+ * Persist a named graph to a .ttl file in embedded mode.
47
+ * Exports the full graph, writes to .opentology/data/{slug}.ttl, and tracks
48
+ * the file in config — mirroring CLI push behavior.
49
+ */
50
+ export async function persistGraph(adapter, config, graphUri) {
51
+ if (config.mode !== 'embedded')
52
+ return;
53
+ const exported = await adapter.exportGraph(graphUri);
54
+ if (!exported.trim())
55
+ return;
56
+ // Derive a filename slug from the graph URI
57
+ const slug = graphUri.replace(/[^a-zA-Z0-9-]/g, '_').replace(/_+/g, '_');
58
+ const dataDir = join(process.cwd(), '.opentology', 'data');
59
+ const filePath = join(dataDir, `${slug}.ttl`);
60
+ mkdirSync(dataDir, { recursive: true });
61
+ writeFileSync(filePath, exported, 'utf-8');
62
+ addTrackedFile(config, graphUri, filePath);
63
+ saveConfig(config);
64
+ }
44
65
  async function handleInit(args) {
45
66
  const projectId = args.projectId;
46
67
  const mode = args.mode || 'http';
@@ -100,6 +121,7 @@ async function handlePush(args) {
100
121
  });
101
122
  const adapter = await createReadyAdapter(config);
102
123
  if (replace) {
124
+ await snapshotGraph(adapter, config, graphUri);
103
125
  await adapter.dropGraph(graphUri);
104
126
  }
105
127
  await adapter.insertTurtle(graphUri, content);
@@ -108,6 +130,7 @@ async function handlePush(args) {
108
130
  if (infer !== false) {
109
131
  inference = await materializeInferences(adapter, graphUri);
110
132
  }
133
+ await persistGraph(adapter, config, graphUri);
111
134
  return {
112
135
  success: true,
113
136
  tripleCount: validation.tripleCount,
@@ -199,7 +222,9 @@ async function handleDrop(args) {
199
222
  graph: args.graph,
200
223
  });
201
224
  const adapter = await createReadyAdapter(config);
225
+ await snapshotGraph(adapter, config, graphUri);
202
226
  await adapter.dropGraph(graphUri);
227
+ await persistGraph(adapter, config, graphUri);
203
228
  return { success: true, graphUri };
204
229
  }
205
230
  async function handleDelete(args) {
@@ -213,9 +238,32 @@ async function handleDelete(args) {
213
238
  graph: args.graph,
214
239
  });
215
240
  const adapter = await createReadyAdapter(config);
241
+ await snapshotGraph(adapter, config, graphUri);
216
242
  await adapter.deleteTriples(graphUri, { turtle: content, where });
243
+ await persistGraph(adapter, config, graphUri);
217
244
  return { success: true };
218
245
  }
246
+ async function handleRollback(args) {
247
+ const action = args.action;
248
+ const { config, graphUri } = resolveConfig({
249
+ graphUri: args.graphUri,
250
+ graph: args.graph,
251
+ });
252
+ if (action === 'list') {
253
+ const snapshots = listSnapshots(graphUri);
254
+ return { graphUri, snapshots };
255
+ }
256
+ if (action === 'restore') {
257
+ const to = args.to;
258
+ if (!to) {
259
+ throw new Error('timestamp (to) is required for restore action');
260
+ }
261
+ const adapter = await createReadyAdapter(config);
262
+ await restoreSnapshot(adapter, config, graphUri, to);
263
+ return { success: true, graphUri, restoredTo: to };
264
+ }
265
+ throw new Error(`Unknown rollback action: ${action}. Use 'list' or 'restore'.`);
266
+ }
219
267
  async function handleDiff(args) {
220
268
  const content = args.content;
221
269
  if (!content) {
@@ -287,7 +335,9 @@ async function handleGraphDrop(args) {
287
335
  const config = loadConfig();
288
336
  const graphUri = resolveGraphUri(config, name);
289
337
  const adapter = await createReadyAdapter(config);
338
+ await snapshotGraph(adapter, config, graphUri);
290
339
  await adapter.dropGraph(graphUri);
340
+ await persistGraph(adapter, config, graphUri);
291
341
  const graphs = config.graphs ?? {};
292
342
  delete graphs[name];
293
343
  config.graphs = Object.keys(graphs).length > 0 ? graphs : undefined;
@@ -302,6 +352,7 @@ async function handleInfer(args) {
302
352
  });
303
353
  const adapter = await createReadyAdapter(config);
304
354
  if (clear) {
355
+ await snapshotGraph(adapter, config, graphUri);
305
356
  await clearInferences(adapter, graphUri);
306
357
  return { success: true, cleared: true };
307
358
  }
@@ -328,6 +379,7 @@ async function handleContextScan(args) {
328
379
  const contextUri = `${config.graphUri}/context`;
329
380
  const adapter = await createReadyAdapter(config);
330
381
  pushStats = await pushSymbolTriples(adapter, contextUri, scanResult);
382
+ await persistGraph(adapter, config, contextUri);
331
383
  }
332
384
  catch {
333
385
  // Non-fatal: push is best-effort
@@ -351,6 +403,8 @@ async function handleContextScan(args) {
351
403
  const adapter = await createReadyAdapter(config);
352
404
  if (snapshot.dependencyGraph && snapshot.dependencyGraph.modules.length > 0) {
353
405
  const dg = snapshot.dependencyGraph;
406
+ // Snapshot before destructive scan
407
+ await snapshotGraph(adapter, config, contextUri);
354
408
  // Scoped delete: clear stale module triples before re-insert
355
409
  await adapter.sparqlUpdate(`DELETE WHERE { GRAPH <${contextUri}> { ?m a <https://opentology.dev/vocab#Module> . ?m ?p ?o } }`);
356
410
  // Insert fresh triples
@@ -363,6 +417,7 @@ async function handleContextScan(args) {
363
417
  }
364
418
  await adapter.sparqlUpdate(`INSERT DATA { GRAPH <${contextUri}> {\n${sparqlTriples.join('\n')}\n} }`);
365
419
  moduleStats = { modules: dg.modules.length, edges: dg.edges.length };
420
+ await persistGraph(adapter, config, contextUri);
366
421
  }
367
422
  }
368
423
  catch {
@@ -499,6 +554,20 @@ async function handleContextInit(args) {
499
554
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
500
555
  actions.push('Auto-registered hooks in .claude/settings.json');
501
556
  }
557
+ // Ensure .opentology/snapshots/ is in .gitignore
558
+ const gitignorePath = join(process.cwd(), '.gitignore');
559
+ const snapshotIgnore = '.opentology/snapshots/';
560
+ if (existsSync(gitignorePath)) {
561
+ const gitignoreContent = readFileSync(gitignorePath, 'utf-8');
562
+ if (!gitignoreContent.includes(snapshotIgnore)) {
563
+ writeFileSync(gitignorePath, gitignoreContent.trimEnd() + '\n' + snapshotIgnore + '\n', 'utf-8');
564
+ actions.push('Added .opentology/snapshots/ to .gitignore');
565
+ }
566
+ }
567
+ else {
568
+ writeFileSync(gitignorePath, snapshotIgnore + '\n', 'utf-8');
569
+ actions.push('Created .gitignore with .opentology/snapshots/');
570
+ }
502
571
  // Auto-push Module triples from dependency graph
503
572
  let moduleStats = null;
504
573
  try {
@@ -515,6 +584,7 @@ async function handleContextInit(args) {
515
584
  }
516
585
  await adapter.sparqlUpdate(`INSERT DATA { GRAPH <${contextUri}> {\n${sparqlTriples.join('\n')}\n} }`);
517
586
  moduleStats = { modules: dg.modules.length, edges: dg.edges.length };
587
+ await persistGraph(adapter, config, contextUri);
518
588
  actions.push(`Pushed ${dg.modules.length} Module triples with ${dg.edges.length} dependsOn edges`);
519
589
  }
520
590
  }
@@ -1235,6 +1305,33 @@ export async function startMcpServer() {
1235
1305
  properties: {},
1236
1306
  },
1237
1307
  },
1308
+ {
1309
+ name: 'rollback',
1310
+ description: 'List or restore graph snapshots. Snapshots are automatically created before destructive operations (drop, delete, scan, etc.). Use action=list to see available snapshots, action=restore with a timestamp to restore.',
1311
+ inputSchema: {
1312
+ type: 'object',
1313
+ properties: {
1314
+ action: {
1315
+ type: 'string',
1316
+ enum: ['list', 'restore'],
1317
+ description: 'Action: list available snapshots or restore a specific one',
1318
+ },
1319
+ to: {
1320
+ type: 'string',
1321
+ description: 'Timestamp of the snapshot to restore (required for action=restore)',
1322
+ },
1323
+ graph: {
1324
+ type: 'string',
1325
+ description: 'Logical graph name (as created by graph_create). Resolves to a graph URI via config.',
1326
+ },
1327
+ graphUri: {
1328
+ type: 'string',
1329
+ description: 'Named graph URI (uses config default if omitted)',
1330
+ },
1331
+ },
1332
+ required: ['action'],
1333
+ },
1334
+ },
1238
1335
  ],
1239
1336
  }));
1240
1337
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -1308,8 +1405,16 @@ export async function startMcpServer() {
1308
1405
  case 'context_impact':
1309
1406
  result = await handleContextImpact(args);
1310
1407
  break;
1311
- case 'context_sync':
1312
- result = await syncContext(loadConfig(), process.cwd());
1408
+ case 'context_sync': {
1409
+ const syncConfig = loadConfig();
1410
+ const syncContextUri = `${syncConfig.graphUri}/context`;
1411
+ const syncAdapter = await createReadyAdapter(syncConfig);
1412
+ await snapshotGraph(syncAdapter, syncConfig, syncContextUri);
1413
+ result = await syncContext(syncConfig, process.cwd());
1414
+ break;
1415
+ }
1416
+ case 'rollback':
1417
+ result = await handleRollback(args);
1313
1418
  break;
1314
1419
  case 'doctor':
1315
1420
  result = await runDoctor();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentology",
3
- "version": "0.2.7",
3
+ "version": "0.3.0",
4
4
  "description": "Ontology-powered project memory for AI coding assistants — your codebase as a knowledge graph",
5
5
  "type": "module",
6
6
  "bin": {