opentology 0.2.8 → 0.3.1

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
  }
@@ -27,7 +27,13 @@ export function registerDiff(program) {
27
27
  for (const triple of result.removed) {
28
28
  console.log(pc.red(`- ${triple}`));
29
29
  }
30
- console.log(`\n${result.added.length} added, ${result.removed.length} removed, ${result.unchanged} unchanged`);
30
+ const addedLabel = result.truncated && result.addedCount > result.added.length
31
+ ? `${result.added.length}/${result.addedCount} added (truncated)`
32
+ : `${result.addedCount} added`;
33
+ const removedLabel = result.truncated && result.removedCount > result.removed.length
34
+ ? `${result.removed.length}/${result.removedCount} removed (truncated)`
35
+ : `${result.removedCount} removed`;
36
+ console.log(`\n${addedLabel}, ${removedLabel}, ${result.unchanged} unchanged`);
31
37
  }
32
38
  catch (err) {
33
39
  const message = err.message;
@@ -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);
@@ -75,11 +75,11 @@ export function generateSymbolTriples(result) {
75
75
  }
76
76
  }
77
77
  for (const call of result.methodCalls) {
78
- // Method calls reference by class.method pattern — generate a simple triple
79
- triples.push(`<urn:call:${encodeSegment(call.caller)}> <${OTX}calls> <urn:call:${encodeSegment(call.callee)}> .`);
80
- // Store caller/callee names for queryability
81
- triples.push(`<urn:call:${encodeSegment(call.caller)}> <${OTX}title> "${esc(call.caller)}" .`);
82
- triples.push(`<urn:call:${encodeSegment(call.callee)}> <${OTX}title> "${esc(call.callee)}" .`);
78
+ const callUri = `urn:call:${encodeSegment(call.caller)}--${encodeSegment(call.callee)}`;
79
+ triples.push(`<${callUri}> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <${OTX}MethodCall> .`);
80
+ triples.push(`<${callUri}> <${OTX}callerSymbol> "${esc(call.caller)}" .`);
81
+ triples.push(`<${callUri}> <${OTX}calleeSymbol> "${esc(call.callee)}" .`);
82
+ triples.push(`<${callUri}> <${OTX}title> "${esc(call.caller)} -> ${esc(call.callee)}" .`);
83
83
  }
84
84
  return triples;
85
85
  }
@@ -96,6 +96,8 @@ export async function deleteExistingSymbols(adapter, graphUri, modulePaths) {
96
96
  const modUri = moduleUri(modPath);
97
97
  await adapter.sparqlUpdate(`DELETE WHERE { GRAPH <${graphUri}> { ?s <${OTX}definedIn> <${modUri}> . ?s ?p ?o } }`);
98
98
  }
99
+ // Clean up MethodCall triples (no definedIn link, so delete all and re-insert)
100
+ await adapter.sparqlUpdate(`DELETE WHERE { GRAPH <${graphUri}> { ?s a <${OTX}MethodCall> . ?s ?p ?o } }`);
99
101
  }
100
102
  export async function pushSymbolTriples(adapter, graphUri, result) {
101
103
  // Collect all module paths for scoped delete
@@ -18,10 +18,13 @@ export declare class EmbeddedAdapter implements StoreAdapter {
18
18
  turtle?: string;
19
19
  where?: string;
20
20
  }): Promise<void>;
21
- diffGraph(graphUri: string, localTurtle: string): Promise<{
21
+ diffGraph(graphUri: string, localTurtle: string, limit?: number): Promise<{
22
22
  added: string[];
23
23
  removed: string[];
24
24
  unchanged: number;
25
+ addedCount: number;
26
+ removedCount: number;
27
+ truncated: boolean;
25
28
  }>;
26
29
  getSchemaOverview(graphUri: string): Promise<{
27
30
  prefixes: Record<string, string>;
@@ -148,7 +148,7 @@ export class EmbeddedAdapter {
148
148
  throw new Error('deleteTriples: either options.turtle or options.where must be provided');
149
149
  }
150
150
  }
151
- async diffGraph(graphUri, localTurtle) {
151
+ async diffGraph(graphUri, localTurtle, limit = 50) {
152
152
  const localQuads = await parseTurtle(localTurtle);
153
153
  const localSet = new Set(localQuads.map((q) => `${termToSparql(q.subject)} ${termToSparql(q.predicate)} ${termToSparql(q.object)}`));
154
154
  // Get remote quads from the store
@@ -158,10 +158,18 @@ export class EmbeddedAdapter {
158
158
  const n3q = wasmQuadToN3Quad(q);
159
159
  remoteSet.add(`${termToSparql(n3q.subject)} ${termToSparql(n3q.predicate)} ${termToSparql(n3q.object)}`);
160
160
  }
161
- const added = [...localSet].filter((t) => !remoteSet.has(t));
162
- const removed = [...remoteSet].filter((t) => !localSet.has(t));
161
+ const allAdded = [...localSet].filter((t) => !remoteSet.has(t));
162
+ const allRemoved = [...remoteSet].filter((t) => !localSet.has(t));
163
163
  const unchanged = [...localSet].filter((t) => remoteSet.has(t)).length;
164
- return { added, removed, unchanged };
164
+ const truncated = allAdded.length > limit || allRemoved.length > limit;
165
+ return {
166
+ added: allAdded.slice(0, limit),
167
+ removed: allRemoved.slice(0, limit),
168
+ unchanged,
169
+ addedCount: allAdded.length,
170
+ removedCount: allRemoved.length,
171
+ truncated,
172
+ };
165
173
  }
166
174
  async getSchemaOverview(graphUri) {
167
175
  const tripleCount = await this.getGraphTripleCount(graphUri);
@@ -14,10 +14,13 @@ export declare class HttpAdapter implements StoreAdapter {
14
14
  turtle?: string;
15
15
  where?: string;
16
16
  }): Promise<void>;
17
- diffGraph(graphUri: string, localTurtle: string): Promise<{
17
+ diffGraph(graphUri: string, localTurtle: string, limit?: number): Promise<{
18
18
  added: string[];
19
19
  removed: string[];
20
20
  unchanged: number;
21
+ addedCount: number;
22
+ removedCount: number;
23
+ truncated: boolean;
21
24
  }>;
22
25
  getSchemaOverview(graphUri: string): Promise<{
23
26
  prefixes: Record<string, string>;
@@ -115,7 +115,7 @@ export class HttpAdapter {
115
115
  throw new Error('deleteTriples: either options.turtle or options.where must be provided');
116
116
  }
117
117
  }
118
- async diffGraph(graphUri, localTurtle) {
118
+ async diffGraph(graphUri, localTurtle, limit = 50) {
119
119
  const localQuads = await parseTurtle(localTurtle);
120
120
  const localSet = new Set(localQuads.map((q) => `${termToSparql(q.subject)} ${termToSparql(q.predicate)} ${termToSparql(q.object)}`));
121
121
  // Fetch remote quads via CONSTRUCT
@@ -125,10 +125,18 @@ export class HttpAdapter {
125
125
  ? await parseTurtle(remoteTurtle)
126
126
  : [];
127
127
  const remoteSet = new Set(remoteQuads.map((q) => `${termToSparql(q.subject)} ${termToSparql(q.predicate)} ${termToSparql(q.object)}`));
128
- const added = [...localSet].filter((t) => !remoteSet.has(t));
129
- const removed = [...remoteSet].filter((t) => !localSet.has(t));
128
+ const allAdded = [...localSet].filter((t) => !remoteSet.has(t));
129
+ const allRemoved = [...remoteSet].filter((t) => !localSet.has(t));
130
130
  const unchanged = [...localSet].filter((t) => remoteSet.has(t)).length;
131
- return { added, removed, unchanged };
131
+ const truncated = allAdded.length > limit || allRemoved.length > limit;
132
+ return {
133
+ added: allAdded.slice(0, limit),
134
+ removed: allRemoved.slice(0, limit),
135
+ unchanged,
136
+ addedCount: allAdded.length,
137
+ removedCount: allRemoved.length,
138
+ truncated,
139
+ };
132
140
  }
133
141
  async getSchemaOverview(graphUri) {
134
142
  const tripleCount = await this.getGraphTripleCount(graphUri);
@@ -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
+ }
@@ -34,10 +34,13 @@ export interface StoreAdapter {
34
34
  }): Promise<void>;
35
35
  getGraphTripleCount(graphUri: string): Promise<number>;
36
36
  exportGraph(graphUri: string): Promise<string>;
37
- diffGraph(graphUri: string, localTurtle: string): Promise<{
37
+ diffGraph(graphUri: string, localTurtle: string, limit?: number): Promise<{
38
38
  added: string[];
39
39
  removed: string[];
40
40
  unchanged: number;
41
+ addedCount: number;
42
+ removedCount: number;
43
+ truncated: boolean;
41
44
  }>;
42
45
  getSchemaOverview(graphUri: string): Promise<{
43
46
  prefixes: Record<string, string>;
@@ -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,21 +238,44 @@ 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) {
245
270
  throw new Error('content (Turtle) is required');
246
271
  }
272
+ const limit = typeof args.limit === 'number' ? args.limit : 50;
247
273
  const { config, graphUri } = resolveConfig({
248
274
  graphUri: args.graphUri,
249
275
  graph: args.graph,
250
276
  });
251
277
  const adapter = await createReadyAdapter(config);
252
- return await adapter.diffGraph(graphUri, content);
278
+ return await adapter.diffGraph(graphUri, content, limit);
253
279
  }
254
280
  async function handleGraphList(_args) {
255
281
  const config = loadConfig();
@@ -310,6 +336,7 @@ async function handleGraphDrop(args) {
310
336
  const config = loadConfig();
311
337
  const graphUri = resolveGraphUri(config, name);
312
338
  const adapter = await createReadyAdapter(config);
339
+ await snapshotGraph(adapter, config, graphUri);
313
340
  await adapter.dropGraph(graphUri);
314
341
  await persistGraph(adapter, config, graphUri);
315
342
  const graphs = config.graphs ?? {};
@@ -326,10 +353,13 @@ async function handleInfer(args) {
326
353
  });
327
354
  const adapter = await createReadyAdapter(config);
328
355
  if (clear) {
356
+ await snapshotGraph(adapter, config, graphUri);
329
357
  await clearInferences(adapter, graphUri);
358
+ await persistGraph(adapter, config, graphUri);
330
359
  return { success: true, cleared: true };
331
360
  }
332
361
  const result = await materializeInferences(adapter, graphUri);
362
+ await persistGraph(adapter, config, graphUri);
333
363
  return result;
334
364
  }
335
365
  async function handleContextScan(args) {
@@ -362,7 +392,7 @@ async function handleContextScan(args) {
362
392
  pushStats,
363
393
  _experimental: true,
364
394
  _hint: pushStats
365
- ? `Symbol triples pushed: ${pushStats.triplesInserted} triples in ${pushStats.batchCount} batches. Query with: SELECT ?c WHERE { ?c a otx:Class ; otx:definedIn <urn:module:...> }`
395
+ ? `Symbol triples pushed: ${pushStats.triplesInserted} triples in ${pushStats.batchCount} batches. Query examples:\n- Classes: SELECT ?c ?name WHERE { ?c a otx:Class ; otx:title ?name }\n- Functions in a module: SELECT ?f ?name WHERE { ?f a otx:Function ; otx:title ?name ; otx:definedIn <urn:module:src/mcp/server> }\n- Call graph: SELECT ?caller ?callee WHERE { ?s a otx:MethodCall ; otx:callerSymbol ?caller ; otx:calleeSymbol ?callee }`
366
396
  : 'Deep scan completed but triple push failed. Use push manually with the generated triples.',
367
397
  };
368
398
  }
@@ -376,6 +406,8 @@ async function handleContextScan(args) {
376
406
  const adapter = await createReadyAdapter(config);
377
407
  if (snapshot.dependencyGraph && snapshot.dependencyGraph.modules.length > 0) {
378
408
  const dg = snapshot.dependencyGraph;
409
+ // Snapshot before destructive scan
410
+ await snapshotGraph(adapter, config, contextUri);
379
411
  // Scoped delete: clear stale module triples before re-insert
380
412
  await adapter.sparqlUpdate(`DELETE WHERE { GRAPH <${contextUri}> { ?m a <https://opentology.dev/vocab#Module> . ?m ?p ?o } }`);
381
413
  // Insert fresh triples
@@ -525,6 +557,20 @@ async function handleContextInit(args) {
525
557
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
526
558
  actions.push('Auto-registered hooks in .claude/settings.json');
527
559
  }
560
+ // Ensure .opentology/snapshots/ is in .gitignore
561
+ const gitignorePath = join(process.cwd(), '.gitignore');
562
+ const snapshotIgnore = '.opentology/snapshots/';
563
+ if (existsSync(gitignorePath)) {
564
+ const gitignoreContent = readFileSync(gitignorePath, 'utf-8');
565
+ if (!gitignoreContent.includes(snapshotIgnore)) {
566
+ writeFileSync(gitignorePath, gitignoreContent.trimEnd() + '\n' + snapshotIgnore + '\n', 'utf-8');
567
+ actions.push('Added .opentology/snapshots/ to .gitignore');
568
+ }
569
+ }
570
+ else {
571
+ writeFileSync(gitignorePath, snapshotIgnore + '\n', 'utf-8');
572
+ actions.push('Created .gitignore with .opentology/snapshots/');
573
+ }
528
574
  // Auto-push Module triples from dependency graph
529
575
  let moduleStats = null;
530
576
  try {
@@ -560,6 +606,7 @@ async function handleContextInit(args) {
560
606
  moduleStats,
561
607
  dependencyHint,
562
608
  hooksAutoInstalled: hooksChanged,
609
+ scanSuggestion: 'Run context_scan to populate the knowledge graph. Use depth="module" for file-level dependencies, or depth="symbol" (with includeMethodCalls=true) for class/function/call-level analysis. The symbol scan enables queries like "which functions call persistGraph?" from the graph.',
563
610
  };
564
611
  }
565
612
  async function handleContextLoad() {
@@ -1043,7 +1090,7 @@ export async function startMcpServer() {
1043
1090
  },
1044
1091
  {
1045
1092
  name: 'diff',
1046
- description: 'Compare local Turtle content against the remote graph. Returns added triples (in local but not remote), removed triples (in remote but not local), and count of unchanged triples.',
1093
+ description: 'Compare local Turtle content against the remote graph. Returns added triples (in local but not remote), removed triples (in remote but not local), and count of unchanged triples. Output is limited to avoid blowing up LLM context windows.',
1047
1094
  inputSchema: {
1048
1095
  type: 'object',
1049
1096
  properties: {
@@ -1051,6 +1098,10 @@ export async function startMcpServer() {
1051
1098
  type: 'string',
1052
1099
  description: 'Turtle (RDF) content to compare against the remote graph',
1053
1100
  },
1101
+ limit: {
1102
+ type: 'number',
1103
+ description: 'Maximum number of added/removed triples to return (default: 50). Total counts are always included.',
1104
+ },
1054
1105
  graph: {
1055
1106
  type: 'string',
1056
1107
  description: 'Logical graph name (as created by graph_create). Resolves to a graph URI via config.',
@@ -1262,6 +1313,33 @@ export async function startMcpServer() {
1262
1313
  properties: {},
1263
1314
  },
1264
1315
  },
1316
+ {
1317
+ name: 'rollback',
1318
+ 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.',
1319
+ inputSchema: {
1320
+ type: 'object',
1321
+ properties: {
1322
+ action: {
1323
+ type: 'string',
1324
+ enum: ['list', 'restore'],
1325
+ description: 'Action: list available snapshots or restore a specific one',
1326
+ },
1327
+ to: {
1328
+ type: 'string',
1329
+ description: 'Timestamp of the snapshot to restore (required for action=restore)',
1330
+ },
1331
+ graph: {
1332
+ type: 'string',
1333
+ description: 'Logical graph name (as created by graph_create). Resolves to a graph URI via config.',
1334
+ },
1335
+ graphUri: {
1336
+ type: 'string',
1337
+ description: 'Named graph URI (uses config default if omitted)',
1338
+ },
1339
+ },
1340
+ required: ['action'],
1341
+ },
1342
+ },
1265
1343
  ],
1266
1344
  }));
1267
1345
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -1335,8 +1413,19 @@ export async function startMcpServer() {
1335
1413
  case 'context_impact':
1336
1414
  result = await handleContextImpact(args);
1337
1415
  break;
1338
- case 'context_sync':
1339
- result = await syncContext(loadConfig(), process.cwd());
1416
+ case 'context_sync': {
1417
+ const syncConfig = loadConfig();
1418
+ const syncContextUri = `${syncConfig.graphUri}/context`;
1419
+ const syncSessionsUri = `${syncConfig.graphUri}/sessions`;
1420
+ const syncAdapter = await createReadyAdapter(syncConfig);
1421
+ await snapshotGraph(syncAdapter, syncConfig, syncContextUri);
1422
+ result = await syncContext(syncConfig, process.cwd());
1423
+ await persistGraph(syncAdapter, syncConfig, syncContextUri);
1424
+ await persistGraph(syncAdapter, syncConfig, syncSessionsUri);
1425
+ break;
1426
+ }
1427
+ case 'rollback':
1428
+ result = await handleRollback(args);
1340
1429
  break;
1341
1430
  case 'doctor':
1342
1431
  result = await runDoctor();
@@ -26,6 +26,12 @@ This project uses OpenTology as its project context graph.
26
26
  | \`otx:Knowledge\` | Reusable knowledge |
27
27
  | \`otx:Session\` | Session logs |
28
28
  | \`otx:Pattern\` | Recurring patterns/conventions |
29
+ | \`otx:Module\` | Source file module |
30
+ | \`otx:Class\` | Class definition (symbol scan) |
31
+ | \`otx:Interface\` | Interface definition (symbol scan) |
32
+ | \`otx:Function\` | Function definition (symbol scan) |
33
+ | \`otx:Method\` | Method definition (symbol scan) |
34
+ | \`otx:MethodCall\` | Call relationship between symbols (symbol scan) |
29
35
 
30
36
  | Property | Range | Description |
31
37
  |----------|-------|-------------|
@@ -36,6 +42,11 @@ This project uses OpenTology as its project context graph.
36
42
  | \`otx:reason\` | string | Decision rationale |
37
43
  | \`otx:nextTodo\` | string | Next action item |
38
44
  | \`otx:relatedTo\` | resource | Related entity |
45
+ | \`otx:dependsOn\` | Module | Module import dependency |
46
+ | \`otx:definedIn\` | Module | Which module a symbol belongs to |
47
+ | \`otx:callerSymbol\` | string | Caller in a MethodCall |
48
+ | \`otx:calleeSymbol\` | string | Callee in a MethodCall |
49
+ | \`otx:calls\` | resource | Call relationship |
39
50
 
40
51
  ### When to Record
41
52
 
@@ -107,13 +118,48 @@ If impact is **high**, inform the user of affected modules and get confirmation
107
118
 
108
119
  #### Searching the Knowledge Graph
109
120
 
110
- Use \`query\` with SPARQL to find anything in the project graph:
121
+ Use \`query\` with SPARQL to find anything in the project graph. **Always query the graph before reading source files** when investigating code structure, dependencies, or call relationships:
122
+
111
123
  - **Decisions**: \`?s a otx:Decision\` — why architectural choices were made
112
124
  - **Issues**: \`?s a otx:Issue ; otx:status "open"\` — known bugs and their status
113
125
  - **Knowledge**: \`?s a otx:Knowledge\` — reusable patterns and lessons learned
114
126
  - **Sessions**: query the sessions graph for past work logs and next TODOs
115
127
  - **Modules**: \`?s a otx:Module\` — all scanned source modules and their dependencies (\`otx:dependsOn\`)
116
128
  - **Symbols**: \`?s a otx:Class\`, \`otx:Interface\`, \`otx:Function\`, \`otx:Method\` — code-level entities (available after symbol-depth scan)
129
+ - **Call graph**: \`?s a otx:MethodCall\` — who calls whom (available after symbol scan with \`includeMethodCalls=true\`)
130
+
131
+ \`\`\`sparql
132
+ # Functions in a specific module
133
+ PREFIX otx: <https://opentology.dev/vocab#>
134
+ SELECT ?name WHERE {
135
+ GRAPH <${contextUri}> {
136
+ ?f a otx:Function ; otx:title ?name ; otx:definedIn <urn:module:src/mcp/server> .
137
+ }
138
+ }
139
+
140
+ # Who calls a specific function?
141
+ PREFIX otx: <https://opentology.dev/vocab#>
142
+ SELECT ?caller WHERE {
143
+ GRAPH <${contextUri}> {
144
+ ?s a otx:MethodCall ; otx:callerSymbol ?caller ; otx:calleeSymbol ?callee .
145
+ FILTER(CONTAINS(?callee, "persistGraph"))
146
+ }
147
+ }
148
+
149
+ # Module dependency chain (what depends on a module?)
150
+ PREFIX otx: <https://opentology.dev/vocab#>
151
+ SELECT ?dependent WHERE {
152
+ GRAPH <${contextUri}> {
153
+ ?dependent otx:dependsOn+ <urn:module:src/lib/store-adapter> .
154
+ }
155
+ }
156
+ \`\`\`
157
+
158
+ #### Post-Edit Graph Update
159
+
160
+ After significant code changes (new files, renamed functions, changed dependencies), run \`context_scan\` to keep the knowledge graph in sync:
161
+ - \`depth="module"\` — fast, updates file-level imports
162
+ - \`depth="symbol"\` with \`includeMethodCalls=true\` — thorough, updates class/function/call graph
117
163
 
118
164
  #### Other Useful Tools
119
165
 
@@ -1 +1 @@
1
- export declare const OTX_BOOTSTRAP_TURTLE = "@prefix otx: <https://opentology.dev/vocab#> .\n@prefix owl: <http://www.w3.org/2002/07/owl#> .\n@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .\n@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .\n\notx:Project a owl:Class .\notx:Decision a owl:Class .\notx:Issue a owl:Class .\notx:Knowledge a owl:Class .\notx:Session a owl:Class .\notx:Pattern a owl:Class .\notx:Module a owl:Class .\n\notx:title a owl:DatatypeProperty ; rdfs:range xsd:string .\notx:date a owl:DatatypeProperty ; rdfs:range xsd:date .\notx:body a owl:DatatypeProperty ; rdfs:range xsd:string .\notx:status a owl:DatatypeProperty ; rdfs:range xsd:string .\notx:reason a owl:DatatypeProperty ; rdfs:range xsd:string .\notx:cause a owl:DatatypeProperty ; rdfs:range xsd:string .\notx:solution a owl:DatatypeProperty ; rdfs:range xsd:string .\notx:nextTodo a owl:DatatypeProperty ; rdfs:range xsd:string .\notx:relatedTo a owl:ObjectProperty .\notx:project a owl:ObjectProperty .\notx:dependsOn a owl:ObjectProperty ; rdfs:domain otx:Module ; rdfs:range otx:Module .\notx:stack a owl:DatatypeProperty ; rdfs:range xsd:string .\notx:alternative a owl:DatatypeProperty ; rdfs:range xsd:string .\n\notx:Class a owl:Class .\notx:Interface a owl:Class .\notx:Function a owl:Class .\notx:Method a owl:Class .\n\notx:definedIn a owl:ObjectProperty ; rdfs:range otx:Module .\notx:extends a owl:ObjectProperty ; rdfs:domain otx:Class ; rdfs:range otx:Class .\notx:implements a owl:ObjectProperty ; rdfs:domain otx:Class ; rdfs:range otx:Interface .\notx:hasMethod a owl:ObjectProperty ; rdfs:domain otx:Class ; rdfs:range otx:Method .\notx:calls a owl:ObjectProperty ; rdfs:domain otx:Method ; rdfs:range otx:Method .\notx:returns a owl:DatatypeProperty ; rdfs:range xsd:string .\notx:paramType a owl:DatatypeProperty ; rdfs:range xsd:string .\n";
1
+ export declare const OTX_BOOTSTRAP_TURTLE = "@prefix otx: <https://opentology.dev/vocab#> .\n@prefix owl: <http://www.w3.org/2002/07/owl#> .\n@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .\n@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .\n\notx:Project a owl:Class .\notx:Decision a owl:Class .\notx:Issue a owl:Class .\notx:Knowledge a owl:Class .\notx:Session a owl:Class .\notx:Pattern a owl:Class .\notx:Module a owl:Class .\n\notx:title a owl:DatatypeProperty ; rdfs:range xsd:string .\notx:date a owl:DatatypeProperty ; rdfs:range xsd:date .\notx:body a owl:DatatypeProperty ; rdfs:range xsd:string .\notx:status a owl:DatatypeProperty ; rdfs:range xsd:string .\notx:reason a owl:DatatypeProperty ; rdfs:range xsd:string .\notx:cause a owl:DatatypeProperty ; rdfs:range xsd:string .\notx:solution a owl:DatatypeProperty ; rdfs:range xsd:string .\notx:nextTodo a owl:DatatypeProperty ; rdfs:range xsd:string .\notx:relatedTo a owl:ObjectProperty .\notx:project a owl:ObjectProperty .\notx:dependsOn a owl:ObjectProperty ; rdfs:domain otx:Module ; rdfs:range otx:Module .\notx:stack a owl:DatatypeProperty ; rdfs:range xsd:string .\notx:alternative a owl:DatatypeProperty ; rdfs:range xsd:string .\n\notx:Class a owl:Class .\notx:Interface a owl:Class .\notx:Function a owl:Class .\notx:Method a owl:Class .\notx:MethodCall a owl:Class .\n\notx:definedIn a owl:ObjectProperty ; rdfs:range otx:Module .\notx:extends a owl:ObjectProperty ; rdfs:domain otx:Class ; rdfs:range otx:Class .\notx:implements a owl:ObjectProperty ; rdfs:domain otx:Class ; rdfs:range otx:Interface .\notx:hasMethod a owl:ObjectProperty ; rdfs:domain otx:Class ; rdfs:range otx:Method .\notx:calls a owl:ObjectProperty .\notx:callerSymbol a owl:ObjectProperty ; rdfs:domain otx:MethodCall .\notx:calleeSymbol a owl:ObjectProperty ; rdfs:domain otx:MethodCall .\notx:returns a owl:DatatypeProperty ; rdfs:range xsd:string .\notx:paramType a owl:DatatypeProperty ; rdfs:range xsd:string .\n";
@@ -30,12 +30,15 @@ otx:Class a owl:Class .
30
30
  otx:Interface a owl:Class .
31
31
  otx:Function a owl:Class .
32
32
  otx:Method a owl:Class .
33
+ otx:MethodCall a owl:Class .
33
34
 
34
35
  otx:definedIn a owl:ObjectProperty ; rdfs:range otx:Module .
35
36
  otx:extends a owl:ObjectProperty ; rdfs:domain otx:Class ; rdfs:range otx:Class .
36
37
  otx:implements a owl:ObjectProperty ; rdfs:domain otx:Class ; rdfs:range otx:Interface .
37
38
  otx:hasMethod a owl:ObjectProperty ; rdfs:domain otx:Class ; rdfs:range otx:Method .
38
- otx:calls a owl:ObjectProperty ; rdfs:domain otx:Method ; rdfs:range otx:Method .
39
+ otx:calls a owl:ObjectProperty .
40
+ otx:callerSymbol a owl:ObjectProperty ; rdfs:domain otx:MethodCall .
41
+ otx:calleeSymbol a owl:ObjectProperty ; rdfs:domain otx:MethodCall .
39
42
  otx:returns a owl:DatatypeProperty ; rdfs:range xsd:string .
40
43
  otx:paramType a owl:DatatypeProperty ; rdfs:range xsd:string .
41
44
  `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentology",
3
- "version": "0.2.8",
3
+ "version": "0.3.1",
4
4
  "description": "Ontology-powered project memory for AI coding assistants — your codebase as a knowledge graph",
5
5
  "type": "module",
6
6
  "bin": {