opentology 0.2.8 → 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
+ }
@@ -6,6 +6,7 @@ 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';
@@ -120,6 +121,7 @@ async function handlePush(args) {
120
121
  });
121
122
  const adapter = await createReadyAdapter(config);
122
123
  if (replace) {
124
+ await snapshotGraph(adapter, config, graphUri);
123
125
  await adapter.dropGraph(graphUri);
124
126
  }
125
127
  await adapter.insertTurtle(graphUri, content);
@@ -220,6 +222,7 @@ async function handleDrop(args) {
220
222
  graph: args.graph,
221
223
  });
222
224
  const adapter = await createReadyAdapter(config);
225
+ await snapshotGraph(adapter, config, graphUri);
223
226
  await adapter.dropGraph(graphUri);
224
227
  await persistGraph(adapter, config, graphUri);
225
228
  return { success: true, graphUri };
@@ -235,10 +238,32 @@ async function handleDelete(args) {
235
238
  graph: args.graph,
236
239
  });
237
240
  const adapter = await createReadyAdapter(config);
241
+ await snapshotGraph(adapter, config, graphUri);
238
242
  await adapter.deleteTriples(graphUri, { turtle: content, where });
239
243
  await persistGraph(adapter, config, graphUri);
240
244
  return { success: true };
241
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
+ }
242
267
  async function handleDiff(args) {
243
268
  const content = args.content;
244
269
  if (!content) {
@@ -310,6 +335,7 @@ async function handleGraphDrop(args) {
310
335
  const config = loadConfig();
311
336
  const graphUri = resolveGraphUri(config, name);
312
337
  const adapter = await createReadyAdapter(config);
338
+ await snapshotGraph(adapter, config, graphUri);
313
339
  await adapter.dropGraph(graphUri);
314
340
  await persistGraph(adapter, config, graphUri);
315
341
  const graphs = config.graphs ?? {};
@@ -326,6 +352,7 @@ async function handleInfer(args) {
326
352
  });
327
353
  const adapter = await createReadyAdapter(config);
328
354
  if (clear) {
355
+ await snapshotGraph(adapter, config, graphUri);
329
356
  await clearInferences(adapter, graphUri);
330
357
  return { success: true, cleared: true };
331
358
  }
@@ -376,6 +403,8 @@ async function handleContextScan(args) {
376
403
  const adapter = await createReadyAdapter(config);
377
404
  if (snapshot.dependencyGraph && snapshot.dependencyGraph.modules.length > 0) {
378
405
  const dg = snapshot.dependencyGraph;
406
+ // Snapshot before destructive scan
407
+ await snapshotGraph(adapter, config, contextUri);
379
408
  // Scoped delete: clear stale module triples before re-insert
380
409
  await adapter.sparqlUpdate(`DELETE WHERE { GRAPH <${contextUri}> { ?m a <https://opentology.dev/vocab#Module> . ?m ?p ?o } }`);
381
410
  // Insert fresh triples
@@ -525,6 +554,20 @@ async function handleContextInit(args) {
525
554
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
526
555
  actions.push('Auto-registered hooks in .claude/settings.json');
527
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
+ }
528
571
  // Auto-push Module triples from dependency graph
529
572
  let moduleStats = null;
530
573
  try {
@@ -1262,6 +1305,33 @@ export async function startMcpServer() {
1262
1305
  properties: {},
1263
1306
  },
1264
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
+ },
1265
1335
  ],
1266
1336
  }));
1267
1337
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -1335,8 +1405,16 @@ export async function startMcpServer() {
1335
1405
  case 'context_impact':
1336
1406
  result = await handleContextImpact(args);
1337
1407
  break;
1338
- case 'context_sync':
1339
- 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);
1340
1418
  break;
1341
1419
  case 'doctor':
1342
1420
  result = await runDoctor();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentology",
3
- "version": "0.2.8",
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": {