opentology 0.3.1 → 0.3.3

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/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  > Ontology-powered project memory for AI coding assistants — your codebase as a knowledge graph
4
4
 
5
+ [![npm](https://img.shields.io/npm/v/opentology)](https://www.npmjs.com/package/opentology) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![Live Demo](https://img.shields.io/badge/Live_Demo-opentology.dev-6c63ff)](https://opentology.dev)
6
+
5
7
  [English](#english) | [한국어](#한국어)
6
8
 
7
9
  ---
@@ -14,6 +16,8 @@ When you connect OpenTology to Claude Code (or any MCP client), it doesn't just
14
16
 
15
17
  The result: an AI assistant that remembers across sessions, understands your codebase structure, and thinks before it acts.
16
18
 
19
+ **[See the live knowledge graph at opentology.dev](https://opentology.dev)** — OpenTology scanning its own codebase: 67 modules, 234 call relations, fully interactive.
20
+
17
21
  ### How It Works
18
22
 
19
23
  ```
@@ -294,6 +298,8 @@ OpenTology를 Claude Code(또는 MCP 호환 클라이언트)에 연결하면,
294
298
 
295
299
  결과: 세션을 넘어 기억하고, 코드베이스 구조를 이해하며, 행동 전에 생각하는 AI 어시스턴트.
296
300
 
301
+ **[opentology.dev에서 라이브 지식 그래프 확인](https://opentology.dev)** — OpenTology가 자기 자신의 코드베이스를 스캔한 결과: 67개 모듈, 234개 호출 관계, 인터랙티브 탐색 가능.
302
+
297
303
  ### 작동 방식
298
304
 
299
305
  ```
@@ -10,6 +10,8 @@ import { OTX_BOOTSTRAP_TURTLE } from '../templates/otx-ontology.js';
10
10
  import { generateContextSection, updateClaudeMd } from '../templates/claude-md-context.js';
11
11
  import { generateHookScript } from '../templates/session-start-hook.js';
12
12
  import { generatePreEditHookScript } from '../templates/pre-edit-hook.js';
13
+ import { generateUserPromptHookScript } from '../templates/user-prompt-hook.js';
14
+ import { generatePostErrorHookScript } from '../templates/post-error-hook.js';
13
15
  import { generateSlashCommands } from '../templates/slash-commands.js';
14
16
  import { normalizeModuleUri } from '../lib/module-uri.js';
15
17
  function ask(question) {
@@ -110,6 +112,26 @@ export function registerContext(program) {
110
112
  else {
111
113
  console.log(pc.dim(' Pre-edit hook already exists — skipped'));
112
114
  }
115
+ // Step 3c: Write user-prompt hook script
116
+ const userPromptHookPath = join(hookDir, 'user-prompt.mjs');
117
+ if (!existsSync(userPromptHookPath) || opts.force) {
118
+ mkdirSync(hookDir, { recursive: true });
119
+ writeFileSync(userPromptHookPath, generateUserPromptHookScript(), 'utf-8');
120
+ console.log(pc.green(` Generated hook script: .opentology/hooks/user-prompt.mjs`));
121
+ }
122
+ else {
123
+ console.log(pc.dim(' User-prompt hook already exists — skipped'));
124
+ }
125
+ // Step 3d: Write post-error hook script
126
+ const postErrorHookPath = join(hookDir, 'post-error.mjs');
127
+ if (!existsSync(postErrorHookPath) || opts.force) {
128
+ mkdirSync(hookDir, { recursive: true });
129
+ writeFileSync(postErrorHookPath, generatePostErrorHookScript(), 'utf-8');
130
+ console.log(pc.green(` Generated hook script: .opentology/hooks/post-error.mjs`));
131
+ }
132
+ else {
133
+ console.log(pc.dim(' Post-error hook already exists — skipped'));
134
+ }
113
135
  // Step 4: Update CLAUDE.md
114
136
  const claudeMdPath = join(process.cwd(), 'CLAUDE.md');
115
137
  const section = generateContextSection(config.projectId, config.graphUri);
@@ -190,12 +212,13 @@ export function registerContext(program) {
190
212
  });
191
213
  console.log(pc.green(' Registered SessionStart hook in .claude/settings.json'));
192
214
  }
193
- // Ask about PreToolUse impact hook
215
+ // Ask about smart hooks (PreToolUse, UserPromptSubmit, PostToolUse)
194
216
  console.log('');
195
- const enableImpact = await ask(pc.bold('Enable PreToolUse impact analysis hook? ') +
196
- pc.dim('(auto-shows dependents before Edit/Write)') +
217
+ const enableHooks = await ask(pc.bold('Enable smart context hooks? ') +
218
+ pc.dim('(auto-impact before edits, keyword search on prompts, error pattern matching)') +
197
219
  ' [Y/n] ');
198
- if (enableImpact !== 'n' && enableImpact !== 'no') {
220
+ if (enableHooks !== 'n' && enableHooks !== 'no') {
221
+ // PreToolUse: impact analysis before Edit/Write
199
222
  const preEditCmd = 'node .opentology/hooks/pre-edit.mjs';
200
223
  if (!hooks.PreToolUse)
201
224
  hooks.PreToolUse = [];
@@ -210,10 +233,42 @@ export function registerContext(program) {
210
233
  hooks: [{ type: 'command', command: preEditCmd }],
211
234
  });
212
235
  }
213
- console.log(pc.green(' Registered PreToolUse impact hook in .claude/settings.json'));
236
+ console.log(pc.green(' Registered PreToolUse impact hook'));
237
+ // UserPromptSubmit: keyword-based context search
238
+ const userPromptCmd = 'node .opentology/hooks/user-prompt.mjs';
239
+ if (!hooks.UserPromptSubmit)
240
+ hooks.UserPromptSubmit = [];
241
+ const hasUserPromptHook = hooks.UserPromptSubmit.some((h) => {
242
+ const entry = h;
243
+ const entryHooks = entry.hooks;
244
+ return entryHooks?.some((hook) => hook.command === userPromptCmd);
245
+ });
246
+ if (!hasUserPromptHook) {
247
+ hooks.UserPromptSubmit.push({
248
+ matcher: '',
249
+ hooks: [{ type: 'command', command: userPromptCmd }],
250
+ });
251
+ }
252
+ console.log(pc.green(' Registered UserPromptSubmit context search hook'));
253
+ // PostToolUse: error pattern matching on Bash
254
+ const postErrorCmd = 'node .opentology/hooks/post-error.mjs';
255
+ if (!hooks.PostToolUse)
256
+ hooks.PostToolUse = [];
257
+ const hasPostErrorHook = hooks.PostToolUse.some((h) => {
258
+ const entry = h;
259
+ const entryHooks = entry.hooks;
260
+ return entryHooks?.some((hook) => hook.command === postErrorCmd);
261
+ });
262
+ if (!hasPostErrorHook) {
263
+ hooks.PostToolUse.push({
264
+ matcher: 'Bash',
265
+ hooks: [{ type: 'command', command: postErrorCmd }],
266
+ });
267
+ }
268
+ console.log(pc.green(' Registered PostToolUse error pattern hook'));
214
269
  }
215
270
  else {
216
- console.log(pc.dim(' Skipped PreToolUse hook registration'));
271
+ console.log(pc.dim(' Skipped smart hook registration'));
217
272
  }
218
273
  settings.hooks = hooks;
219
274
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
@@ -579,6 +634,103 @@ export function registerContext(program) {
579
634
  process.exit(1);
580
635
  }
581
636
  });
637
+ // --- context search ---
638
+ context
639
+ .command('search')
640
+ .description('Search the knowledge graph by keywords (Issues, Decisions, Knowledge, Patterns)')
641
+ .requiredOption('--keywords <words>', 'Comma-separated keywords to search for')
642
+ .option('--types <types>', 'Comma-separated types to search (Issue,Decision,Knowledge,Pattern)', 'Issue,Decision,Knowledge,Pattern')
643
+ .option('--limit <n>', 'Maximum results', '10')
644
+ .option('--format <type>', 'Output format: table, json', 'table')
645
+ .action(async (opts) => {
646
+ let config;
647
+ try {
648
+ config = loadConfig();
649
+ }
650
+ catch {
651
+ console.error(pc.red('Error: No .opentology.json found. Run `opentology init` first.'));
652
+ process.exit(1);
653
+ }
654
+ const graphs = config.graphs ?? {};
655
+ if (!graphs['context']) {
656
+ console.error(pc.red('Error: Context not initialized. Run `opentology context init` first.'));
657
+ process.exit(1);
658
+ }
659
+ const contextUri = graphs['context'];
660
+ const OTX = 'https://opentology.dev/vocab#';
661
+ const keywords = opts.keywords.split(',').map(k => k.trim().toLowerCase()).filter(Boolean);
662
+ if (keywords.length === 0) {
663
+ console.error(pc.red('Error: No keywords provided.'));
664
+ process.exit(1);
665
+ }
666
+ const types = opts.types.split(',').map(t => t.trim()).filter(Boolean);
667
+ const typeFilter = types.map(t => `<${OTX}${t}>`).join(', ');
668
+ const limit = parseInt(opts.limit, 10) || 10;
669
+ // Build FILTER clause: match any keyword in title or body
670
+ const keywordFilters = keywords.map(kw => {
671
+ const escaped = kw.replace(/"/g, '\\"');
672
+ return `CONTAINS(LCASE(?title), "${escaped}") || CONTAINS(LCASE(COALESCE(?body, "")), "${escaped}")`;
673
+ });
674
+ const filterClause = keywordFilters.join(' || ');
675
+ try {
676
+ const adapter = await createReadyAdapter(config);
677
+ const sparql = `
678
+ PREFIX otx: <${OTX}>
679
+ SELECT ?type ?title ?body ?status ?date ?solution ?cause ?reason WHERE {
680
+ GRAPH <${contextUri}> {
681
+ ?s a ?type .
682
+ ?s otx:title ?title .
683
+ OPTIONAL { ?s otx:body ?body }
684
+ OPTIONAL { ?s otx:status ?status }
685
+ OPTIONAL { ?s otx:date ?date }
686
+ OPTIONAL { ?s otx:solution ?solution }
687
+ OPTIONAL { ?s otx:cause ?cause }
688
+ OPTIONAL { ?s otx:reason ?reason }
689
+ FILTER(
690
+ ?type IN (${typeFilter})
691
+ && (${filterClause})
692
+ )
693
+ }
694
+ } ORDER BY DESC(?date) LIMIT ${limit}
695
+ `;
696
+ const result = await adapter.sparqlQuery(sparql);
697
+ const results = (result.results?.bindings ?? []).map((b) => ({
698
+ type: b.type?.value ?? '',
699
+ title: b.title?.value ?? '',
700
+ body: b.body?.value,
701
+ status: b.status?.value,
702
+ date: b.date?.value,
703
+ solution: b.solution?.value,
704
+ cause: b.cause?.value,
705
+ reason: b.reason?.value,
706
+ }));
707
+ if (opts.format === 'json') {
708
+ console.log(JSON.stringify({ keywords, results }, null, 2));
709
+ }
710
+ else {
711
+ if (results.length === 0) {
712
+ console.log(pc.dim(`No results for keywords: ${keywords.join(', ')}`));
713
+ return;
714
+ }
715
+ console.log(pc.bold(`Search results for: ${keywords.join(', ')}`));
716
+ console.log('');
717
+ for (const r of results) {
718
+ const tag = r.type.replace(OTX, '');
719
+ console.log(` [${pc.bold(tag)}] ${r.title}${r.status ? ` (${r.status})` : ''}${r.date ? ` ${pc.dim(r.date)}` : ''}`);
720
+ if (r.solution)
721
+ console.log(` ${pc.green('Solution:')} ${r.solution}`);
722
+ if (r.cause)
723
+ console.log(` ${pc.yellow('Cause:')} ${r.cause}`);
724
+ if (r.reason)
725
+ console.log(` ${pc.blue('Reason:')} ${r.reason}`);
726
+ }
727
+ }
728
+ }
729
+ catch (err) {
730
+ console.error(pc.red(`Error: ${err.message}`));
731
+ process.exit(1);
732
+ }
733
+ });
582
734
  // --- context graph ---
583
735
  context
584
736
  .command('graph')
package/dist/index.js CHANGED
@@ -1,44 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
3
  import { registerInit } from './commands/init.js';
4
- import { registerValidate } from './commands/validate.js';
5
- import { registerPush } from './commands/push.js';
6
4
  import { registerQuery } from './commands/query.js';
7
- import { registerStatus } from './commands/status.js';
8
- import { registerPull } from './commands/pull.js';
9
- import { registerDrop } from './commands/drop.js';
10
- import { registerDelete } from './commands/delete.js';
11
5
  import { registerMcp } from './commands/mcp.js';
12
- import { registerShapes } from './commands/shapes.js';
13
- import { registerDiff } from './commands/diff.js';
14
- import { registerGraph } from './commands/graph.js';
15
- import { registerInfer } from './commands/infer.js';
16
- import { registerPrefix } from './commands/prefix.js';
17
- import { registerContext } from './commands/context.js';
18
- import { registerViz } from './commands/viz.js';
19
- import { registerDoctor } from './commands/doctor.js';
20
- import { registerRollback } from './commands/rollback.js';
21
6
  const program = new Command();
22
7
  program
23
8
  .name('opentology')
24
9
  .version('0.2.4')
25
10
  .description('CLI-managed RDF/SPARQL infrastructure — Supabase for RDF');
26
11
  registerInit(program);
27
- registerValidate(program);
28
- registerPush(program);
29
12
  registerQuery(program);
30
- registerStatus(program);
31
- registerPull(program);
32
- registerDrop(program);
33
- registerDelete(program);
34
13
  registerMcp(program);
35
- registerShapes(program);
36
- registerDiff(program);
37
- registerGraph(program);
38
- registerInfer(program);
39
- registerPrefix(program);
40
- registerContext(program);
41
- registerViz(program);
42
- registerDoctor(program);
43
- registerRollback(program);
44
14
  program.parse(process.argv);
@@ -0,0 +1,6 @@
1
+ export interface CheckResult {
2
+ name: string;
3
+ status: 'ok' | 'warn' | 'fail';
4
+ message: string;
5
+ }
6
+ export declare function runDoctor(): Promise<CheckResult[]>;
@@ -0,0 +1,99 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { configExists, loadConfig } from './config.js';
4
+ import { createReadyAdapter } from './store-factory.js';
5
+ export async function runDoctor() {
6
+ const results = [];
7
+ // 1. Config check
8
+ if (!configExists()) {
9
+ results.push({ name: 'Config', status: 'fail', message: 'No .opentology.json found. Run `opentology init` first.' });
10
+ return results; // Can't proceed without config
11
+ }
12
+ const config = loadConfig();
13
+ results.push({ name: 'Config', status: 'ok', message: `Project: ${config.projectId} (${config.mode} mode)` });
14
+ // 2. Store connectivity
15
+ try {
16
+ const adapter = await createReadyAdapter(config);
17
+ const r = await adapter.sparqlQuery(`SELECT (COUNT(*) AS ?c) WHERE { GRAPH <${config.graphUri}> { ?s ?p ?o } }`);
18
+ const count = r.results?.bindings?.[0]?.c?.value ?? '0';
19
+ results.push({ name: 'Store', status: 'ok', message: `Connected — ${count} triples in default graph` });
20
+ }
21
+ catch (err) {
22
+ results.push({ name: 'Store', status: 'fail', message: `Cannot connect: ${err.message}` });
23
+ }
24
+ // 3. Context initialization
25
+ const graphs = config.graphs ?? {};
26
+ if (graphs['context'] && graphs['sessions']) {
27
+ results.push({ name: 'Context', status: 'ok', message: `context: ${graphs['context']}` });
28
+ }
29
+ else {
30
+ results.push({ name: 'Context', status: 'warn', message: 'Not initialized. Run `opentology context init`.' });
31
+ }
32
+ // 4. Hook scripts
33
+ const hookDir = join(process.cwd(), '.opentology', 'hooks');
34
+ const sessionHook = join(hookDir, 'session-start.mjs');
35
+ const preEditHook = join(hookDir, 'pre-edit.mjs');
36
+ const hooksExist = existsSync(sessionHook) && existsSync(preEditHook);
37
+ if (hooksExist) {
38
+ results.push({ name: 'Hooks', status: 'ok', message: 'session-start.mjs + pre-edit.mjs present' });
39
+ }
40
+ else {
41
+ results.push({ name: 'Hooks', status: 'warn', message: 'Hook scripts missing. Run `opentology context init`.' });
42
+ }
43
+ // 5. Hook registration in .claude/settings.json
44
+ const settingsPath = join(process.cwd(), '.claude', 'settings.json');
45
+ if (existsSync(settingsPath)) {
46
+ try {
47
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
48
+ const hooks = settings.hooks ?? {};
49
+ const hasSession = (hooks.SessionStart ?? []).some((h) => h.command?.includes('session-start.mjs'));
50
+ const hasPreEdit = (hooks.PreToolUse ?? []).some((h) => h.command?.includes('pre-edit.mjs'));
51
+ if (hasSession && hasPreEdit) {
52
+ results.push({ name: 'Settings', status: 'ok', message: 'Both hooks registered in .claude/settings.json' });
53
+ }
54
+ else {
55
+ const missing = [];
56
+ if (!hasSession)
57
+ missing.push('SessionStart');
58
+ if (!hasPreEdit)
59
+ missing.push('PreToolUse');
60
+ results.push({ name: 'Settings', status: 'warn', message: `Missing hooks: ${missing.join(', ')}. Run \`opentology context init\`.` });
61
+ }
62
+ }
63
+ catch {
64
+ results.push({ name: 'Settings', status: 'warn', message: 'Cannot parse .claude/settings.json' });
65
+ }
66
+ }
67
+ else {
68
+ results.push({ name: 'Settings', status: 'warn', message: 'No .claude/settings.json found. Run `opentology context init`.' });
69
+ }
70
+ // 6. CLAUDE.md
71
+ const claudeMdPath = join(process.cwd(), 'CLAUDE.md');
72
+ if (existsSync(claudeMdPath)) {
73
+ const content = readFileSync(claudeMdPath, 'utf-8');
74
+ if (content.includes('OPENTOLOGY:CONTEXT:BEGIN') || content.includes('OpenTology')) {
75
+ results.push({ name: 'CLAUDE.md', status: 'ok', message: 'Context section present' });
76
+ }
77
+ else {
78
+ results.push({ name: 'CLAUDE.md', status: 'warn', message: 'Exists but no OpenTology context section' });
79
+ }
80
+ }
81
+ else {
82
+ results.push({ name: 'CLAUDE.md', status: 'warn', message: 'Not found. Run `opentology context init`.' });
83
+ }
84
+ // 7. Optional dependencies
85
+ const optDeps = [
86
+ { name: 'ts-morph', desc: 'TypeScript deep scan' },
87
+ { name: 'web-tree-sitter', desc: 'Multi-language deep scan' },
88
+ ];
89
+ for (const dep of optDeps) {
90
+ try {
91
+ await import(dep.name);
92
+ results.push({ name: dep.name, status: 'ok', message: dep.desc });
93
+ }
94
+ catch {
95
+ results.push({ name: dep.name, status: 'warn', message: `Not installed (optional — ${dep.desc})` });
96
+ }
97
+ }
98
+ return results;
99
+ }
@@ -1,7 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
3
  import { getTrackedFiles } from './config.js';
4
- import { HttpAdapter } from './http-adapter.js';
5
4
  import { EmbeddedAdapter } from './embedded-adapter.js';
6
5
  import { materializeInferences } from './reasoner.js';
7
6
  // Singleton cache for embedded mode — keeps data alive across MCP tool calls.
@@ -16,9 +15,6 @@ export function resetAdapterCache() {
16
15
  loadedFileKeys = new Set();
17
16
  }
18
17
  export async function createReadyAdapter(config) {
19
- if (config.mode !== 'embedded') {
20
- return new HttpAdapter(config.endpoint ?? 'http://localhost:7878');
21
- }
22
18
  // Reuse cached adapter so data persists across MCP tool calls.
23
19
  if (!cachedAdapter) {
24
20
  cachedAdapter = new EmbeddedAdapter();
@@ -1,5 +1,32 @@
1
1
  import type { OpenTologyConfig } from '../lib/config.js';
2
2
  import type { StoreAdapter } from '../lib/store-adapter.js';
3
+ export interface ContextLoadOutput {
4
+ projectId: string;
5
+ graphUri: string;
6
+ sessions: Array<{
7
+ uri: string;
8
+ title: string;
9
+ date: string;
10
+ nextTodo?: string;
11
+ }>;
12
+ openIssues: Array<{
13
+ uri: string;
14
+ title: string;
15
+ date: string;
16
+ }>;
17
+ recentDecisions: Array<{
18
+ uri: string;
19
+ title: string;
20
+ date: string;
21
+ reason?: string;
22
+ }>;
23
+ meta: {
24
+ contextTripleCount: number;
25
+ sessionsTripleCount: number;
26
+ loadedAt: string;
27
+ };
28
+ warnings?: string[];
29
+ }
3
30
  export declare const MAX_TRIPLES_PER_PUSH = 100;
4
31
  export declare function assertTripleLimit(tripleCount: number): void;
5
32
  /**
@@ -18,8 +18,10 @@ import { OTX_BOOTSTRAP_TURTLE } from '../templates/otx-ontology.js';
18
18
  import { generateContextSection, updateClaudeMd } from '../templates/claude-md-context.js';
19
19
  import { generateHookScript } from '../templates/session-start-hook.js';
20
20
  import { generatePreEditHookScript } from '../templates/pre-edit-hook.js';
21
+ import { generateUserPromptHookScript } from '../templates/user-prompt-hook.js';
22
+ import { generatePostErrorHookScript } from '../templates/post-error-hook.js';
21
23
  import { generateSlashCommands } from '../templates/slash-commands.js';
22
- import { runDoctor } from '../commands/doctor.js';
24
+ import { runDoctor } from '../lib/doctor.js';
23
25
  import { syncContext } from '../lib/context-sync.js';
24
26
  import { scanCodebase } from '../lib/codebase-scanner.js';
25
27
  import { startGraphServer } from '../lib/graph-server.js';
@@ -482,6 +484,18 @@ async function handleContextInit(args) {
482
484
  writeFileSync(preEditHookPath, generatePreEditHookScript(), 'utf-8');
483
485
  actions.push('Generated hook: .opentology/hooks/pre-edit.mjs');
484
486
  }
487
+ const userPromptHookPath = join(hookDir, 'user-prompt.mjs');
488
+ if (!existsSync(userPromptHookPath) || force) {
489
+ mkdirSync(hookDir, { recursive: true });
490
+ writeFileSync(userPromptHookPath, generateUserPromptHookScript(), 'utf-8');
491
+ actions.push('Generated hook: .opentology/hooks/user-prompt.mjs');
492
+ }
493
+ const postErrorHookPath = join(hookDir, 'post-error.mjs');
494
+ if (!existsSync(postErrorHookPath) || force) {
495
+ mkdirSync(hookDir, { recursive: true });
496
+ writeFileSync(postErrorHookPath, generatePostErrorHookScript(), 'utf-8');
497
+ actions.push('Generated hook: .opentology/hooks/post-error.mjs');
498
+ }
485
499
  // Update CLAUDE.md
486
500
  const claudeMdPath = join(process.cwd(), 'CLAUDE.md');
487
501
  const section = generateContextSection(config.projectId, config.graphUri);
@@ -552,6 +566,38 @@ async function handleContextInit(args) {
552
566
  });
553
567
  hooksChanged = true;
554
568
  }
569
+ // UserPromptSubmit: keyword-based context search
570
+ const userPromptCmd = 'node .opentology/hooks/user-prompt.mjs';
571
+ if (!hooks.UserPromptSubmit)
572
+ hooks.UserPromptSubmit = [];
573
+ const hasUserPromptHook = hooks.UserPromptSubmit.some((h) => {
574
+ const entry = h;
575
+ const entryHooks = entry.hooks;
576
+ return entryHooks?.some((hook) => hook.command === userPromptCmd);
577
+ });
578
+ if (!hasUserPromptHook) {
579
+ hooks.UserPromptSubmit.push({
580
+ matcher: '',
581
+ hooks: [{ type: 'command', command: userPromptCmd }],
582
+ });
583
+ hooksChanged = true;
584
+ }
585
+ // PostToolUse: error pattern matching on Bash
586
+ const postErrorCmd = 'node .opentology/hooks/post-error.mjs';
587
+ if (!hooks.PostToolUse)
588
+ hooks.PostToolUse = [];
589
+ const hasPostErrorHook = hooks.PostToolUse.some((h) => {
590
+ const entry = h;
591
+ const entryHooks = entry.hooks;
592
+ return entryHooks?.some((hook) => hook.command === postErrorCmd);
593
+ });
594
+ if (!hasPostErrorHook) {
595
+ hooks.PostToolUse.push({
596
+ matcher: 'Bash',
597
+ hooks: [{ type: 'command', command: postErrorCmd }],
598
+ });
599
+ hooksChanged = true;
600
+ }
555
601
  if (hooksChanged) {
556
602
  settings.hooks = hooks;
557
603
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
@@ -704,6 +750,54 @@ async function handleContextLoad() {
704
750
  delete output.warnings;
705
751
  return output;
706
752
  }
753
+ async function handleContextSearch(args) {
754
+ const keywords = args.keywords;
755
+ if (!keywords || keywords.length === 0)
756
+ throw new Error('keywords is required (array of strings)');
757
+ const types = args.types ?? ['Issue', 'Decision', 'Knowledge', 'Pattern'];
758
+ const limit = args.limit ?? 10;
759
+ const config = loadConfig();
760
+ const contextUri = `${config.graphUri}/context`;
761
+ const OTX = 'https://opentology.dev/vocab#';
762
+ const adapter = await createReadyAdapter(config);
763
+ const typeFilter = types.map(t => `<${OTX}${t}>`).join(', ');
764
+ const keywordFilters = keywords.map(kw => {
765
+ const escaped = kw.toLowerCase().replace(/"/g, '\\"');
766
+ return `CONTAINS(LCASE(?title), "${escaped}") || CONTAINS(LCASE(COALESCE(?body, "")), "${escaped}")`;
767
+ });
768
+ const filterClause = keywordFilters.join(' || ');
769
+ const sparql = `
770
+ PREFIX otx: <${OTX}>
771
+ SELECT ?type ?title ?body ?status ?date ?solution ?cause ?reason WHERE {
772
+ GRAPH <${contextUri}> {
773
+ ?s a ?type .
774
+ ?s otx:title ?title .
775
+ OPTIONAL { ?s otx:body ?body }
776
+ OPTIONAL { ?s otx:status ?status }
777
+ OPTIONAL { ?s otx:date ?date }
778
+ OPTIONAL { ?s otx:solution ?solution }
779
+ OPTIONAL { ?s otx:cause ?cause }
780
+ OPTIONAL { ?s otx:reason ?reason }
781
+ FILTER(
782
+ ?type IN (${typeFilter})
783
+ && (${filterClause})
784
+ )
785
+ }
786
+ } ORDER BY DESC(?date) LIMIT ${limit}
787
+ `;
788
+ const result = await adapter.sparqlQuery(sparql);
789
+ const results = (result.results?.bindings ?? []).map((b) => ({
790
+ type: b.type?.value ?? '',
791
+ title: b.title?.value ?? '',
792
+ body: b.body?.value,
793
+ status: b.status?.value,
794
+ date: b.date?.value,
795
+ solution: b.solution?.value,
796
+ cause: b.cause?.value,
797
+ reason: b.reason?.value,
798
+ }));
799
+ return { keywords, results };
800
+ }
707
801
  async function handleContextImpact(args) {
708
802
  const filePath = args.filePath;
709
803
  if (!filePath)
@@ -1196,6 +1290,30 @@ export async function startMcpServer() {
1196
1290
  properties: {},
1197
1291
  },
1198
1292
  },
1293
+ {
1294
+ name: 'context_search',
1295
+ description: 'Search the knowledge graph by keywords. Searches across Issues, Decisions, Knowledge, and Patterns by matching keywords against titles and bodies. Use this to find relevant context before investigating code.',
1296
+ inputSchema: {
1297
+ type: 'object',
1298
+ properties: {
1299
+ keywords: {
1300
+ type: 'array',
1301
+ items: { type: 'string' },
1302
+ description: 'Keywords to search for (matched with OR logic against title and body).',
1303
+ },
1304
+ types: {
1305
+ type: 'array',
1306
+ items: { type: 'string' },
1307
+ description: 'OTX types to search (default: Issue, Decision, Knowledge, Pattern). Use short names without prefix.',
1308
+ },
1309
+ limit: {
1310
+ type: 'number',
1311
+ description: 'Maximum results (default: 10).',
1312
+ },
1313
+ },
1314
+ required: ['keywords'],
1315
+ },
1316
+ },
1199
1317
  {
1200
1318
  name: 'context_status',
1201
1319
  description: 'Check whether project context is initialized. Shows graph triple counts, hook script presence, and CLAUDE.md marker status.',
@@ -1398,6 +1516,9 @@ export async function startMcpServer() {
1398
1516
  case 'context_load':
1399
1517
  result = await handleContextLoad();
1400
1518
  break;
1519
+ case 'context_search':
1520
+ result = await handleContextSearch(args);
1521
+ break;
1401
1522
  case 'context_status':
1402
1523
  result = await handleContextStatus();
1403
1524
  break;
@@ -7,7 +7,10 @@ export function generateContextSection(projectId, graphUri) {
7
7
  return `${MARKER_BEGIN}
8
8
  ## Context Management — OpenTology
9
9
 
10
- This project uses OpenTology as its project context graph.
10
+ This project uses OpenTology as its knowledge graph — treat it as the project's **long-term memory**.
11
+ Before reading source files, grep-searching, or making assumptions, **query the graph first**.
12
+ It holds architectural decisions, resolved issues, reusable patterns, module dependencies,
13
+ symbol-level call graphs, and session history. Prefer graph knowledge over re-deriving facts from code.
11
14
 
12
15
  ### Graph Structure
13
16
 
@@ -0,0 +1 @@
1
+ export declare function generatePostErrorHookScript(): string;
@@ -0,0 +1,146 @@
1
+ export function generatePostErrorHookScript() {
2
+ return `#!/usr/bin/env node
3
+ // Generated by: opentology context init
4
+ // PostToolUse hook (Bash) — matches error output against known patterns
5
+ import { existsSync, readFileSync } from 'node:fs';
6
+ import { execFileSync } from 'node:child_process';
7
+ import { join, resolve, dirname } from 'node:path';
8
+
9
+ const TIMEOUT = parseInt(process.env.OPENTOLOGY_HOOK_TIMEOUT || '3000', 10);
10
+
11
+ // Error indicators that trigger pattern search
12
+ const ERROR_PATTERNS = [
13
+ /\\bError\\b/i,
14
+ /\\bERR!\\b/,
15
+ /\\bFailed\\b/i,
16
+ /\\bfatal\\b/i,
17
+ /\\bENOENT\\b/,
18
+ /\\bENOTDIR\\b/,
19
+ /\\bEACCES\\b/,
20
+ /\\bSyntaxError\\b/,
21
+ /\\bTypeError\\b/,
22
+ /\\bReferenceError\\b/,
23
+ /\\bModuleNotFoundError\\b/,
24
+ /\\bexited with code [1-9]/,
25
+ /\\bcommand not found\\b/,
26
+ /\\bCannot find module\\b/,
27
+ /\\bTS\\d{4}:/,
28
+ ];
29
+
30
+ function findProjectRoot(startDir) {
31
+ let dir = resolve(startDir);
32
+ while (dir !== dirname(dir)) {
33
+ if (existsSync(join(dir, '.opentology.json'))) return dir;
34
+ dir = dirname(dir);
35
+ }
36
+ return null;
37
+ }
38
+
39
+ function findBin(projectRoot) {
40
+ const local = join(projectRoot, 'node_modules', '.bin', 'opentology');
41
+ if (existsSync(local)) return { bin: local, args: [] };
42
+ return { bin: 'npx', args: ['opentology'] };
43
+ }
44
+
45
+ function run(projectRoot, cmdArgs) {
46
+ const { bin, args } = findBin(projectRoot);
47
+ return execFileSync(bin, [...args, ...cmdArgs], {
48
+ cwd: projectRoot,
49
+ timeout: TIMEOUT,
50
+ encoding: 'utf-8',
51
+ stdio: ['pipe', 'pipe', 'pipe'],
52
+ });
53
+ }
54
+
55
+ function extractErrorKeywords(output) {
56
+ const keywords = new Set();
57
+
58
+ // Extract error class names (TypeError, SyntaxError, etc.)
59
+ const errorClasses = output.match(/\\b[A-Z][a-zA-Z]*Error\\b/g);
60
+ if (errorClasses) errorClasses.forEach(e => keywords.add(e));
61
+
62
+ // Extract TS error codes
63
+ const tsCodes = output.match(/TS\\d{4}/g);
64
+ if (tsCodes) tsCodes.forEach(c => keywords.add(c));
65
+
66
+ // Extract "Cannot find module 'xxx'"
67
+ const modules = output.match(/Cannot find module ['"]([^'"]+)['"]/g);
68
+ if (modules) modules.forEach(m => {
69
+ const name = m.match(/['"]([^'"]+)['"]/)?.[1];
70
+ if (name) keywords.add(name);
71
+ });
72
+
73
+ // Extract file paths from error output
74
+ const paths = output.match(/(?:src|lib)\\/[\\w/.-]+\\.\\w+/g);
75
+ if (paths) paths.slice(0, 3).forEach(p => keywords.add(p));
76
+
77
+ // Extract npm package names from errors
78
+ const npmPkgs = output.match(/(?:ERR!|error)\\s+([a-z@][a-z0-9@/.-]+)/gi);
79
+ if (npmPkgs) npmPkgs.slice(0, 2).forEach(p => {
80
+ const name = p.replace(/^(?:ERR!|error)\\s+/i, '');
81
+ if (name.length >= 3) keywords.add(name);
82
+ });
83
+
84
+ return [...keywords].slice(0, 5);
85
+ }
86
+
87
+ try {
88
+ const projectRoot = findProjectRoot(process.cwd());
89
+ if (!projectRoot) process.exit(0);
90
+
91
+ // Read tool output from stdin
92
+ let input = '';
93
+ try {
94
+ input = readFileSync('/dev/stdin', 'utf-8');
95
+ } catch {
96
+ process.exit(0);
97
+ }
98
+
99
+ let toolOutput = '';
100
+ let toolName = '';
101
+ try {
102
+ const parsed = JSON.parse(input);
103
+ toolName = parsed.tool_name || '';
104
+ toolOutput = parsed.tool_output || parsed.stdout || parsed.stderr || '';
105
+ } catch {
106
+ toolOutput = input;
107
+ }
108
+
109
+ // Only process Bash tool output
110
+ if (toolName && toolName !== 'Bash') process.exit(0);
111
+
112
+ // Check if output contains error indicators
113
+ if (!toolOutput || toolOutput.length < 10) process.exit(0);
114
+ const hasError = ERROR_PATTERNS.some(p => p.test(toolOutput));
115
+ if (!hasError) process.exit(0);
116
+
117
+ const keywords = extractErrorKeywords(toolOutput);
118
+ if (keywords.length === 0) process.exit(0);
119
+
120
+ const raw = run(projectRoot, [
121
+ 'context', 'search',
122
+ '--keywords', keywords.join(','),
123
+ '--types', 'Pattern,Issue,Knowledge',
124
+ '--format', 'json',
125
+ ]);
126
+ const data = JSON.parse(raw);
127
+
128
+ if (!data.results || data.results.length === 0) process.exit(0);
129
+
130
+ const lines = [];
131
+ lines.push('[Known Pattern Match] Similar errors found in project history:');
132
+ for (const r of data.results.slice(0, 3)) {
133
+ const tag = r.type.replace('https://opentology.dev/vocab#', '');
134
+ lines.push(\`- [\${tag}] \${r.title}\${r.status ? ' (' + r.status + ')' : ''}\`);
135
+ if (r.solution) lines.push(\` Solution: \${r.solution}\`);
136
+ if (r.cause) lines.push(\` Cause: \${r.cause}\`);
137
+ if (r.body && r.body.length <= 150) lines.push(\` \${r.body}\`);
138
+ }
139
+
140
+ console.log(lines.join('\\n'));
141
+ } catch {
142
+ // Silent exit — do not block command execution
143
+ process.exit(0);
144
+ }
145
+ `;
146
+ }
@@ -0,0 +1 @@
1
+ export declare function generateUserPromptHookScript(): string;
@@ -0,0 +1,139 @@
1
+ export function generateUserPromptHookScript() {
2
+ return `#!/usr/bin/env node
3
+ // Generated by: opentology context init
4
+ // UserPromptSubmit hook — extracts keywords from user message and queries related context
5
+ import { existsSync, readFileSync } from 'node:fs';
6
+ import { execFileSync } from 'node:child_process';
7
+ import { join, resolve, dirname } from 'node:path';
8
+
9
+ const TIMEOUT = parseInt(process.env.OPENTOLOGY_HOOK_TIMEOUT || '3000', 10);
10
+
11
+ // Stop words (EN + KO common particles/connectors)
12
+ const STOP_WORDS = new Set([
13
+ // English
14
+ 'the','a','an','is','are','was','were','be','been','being','have','has','had',
15
+ 'do','does','did','will','would','shall','should','may','might','must','can','could',
16
+ 'this','that','these','those','it','its','i','you','he','she','we','they','me','him',
17
+ 'her','us','them','my','your','his','our','their','what','which','who','whom','when',
18
+ 'where','why','how','not','no','nor','but','and','or','if','then','else','for','from',
19
+ 'with','without','about','above','after','before','between','into','through','during',
20
+ 'to','of','in','on','at','by','as','so','than','too','very','just','also','now','here',
21
+ 'there','all','each','every','both','few','more','most','other','some','such','only',
22
+ 'same','let','please','want','need','like','make','get','use','try','look','see','know',
23
+ 'think','help','show','tell','give','take','come','go','run','file','code','check',
24
+ // Korean particles/connectors
25
+ '은','는','이','가','을','를','에','에서','으로','로','와','과','의','도','만',
26
+ '부터','까지','에게','한테','보다','라고','이라고','하고','해서','해줘','해주세요',
27
+ '좀','그','저','것','거','수','때','중','후','전','안','못','잘','더','다',
28
+ '뭐','어떻게','왜','어디','언제','누가','뭘','걸','건','데','게','줘',
29
+ ]);
30
+
31
+ function findProjectRoot(startDir) {
32
+ let dir = resolve(startDir);
33
+ while (dir !== dirname(dir)) {
34
+ if (existsSync(join(dir, '.opentology.json'))) return dir;
35
+ dir = dirname(dir);
36
+ }
37
+ return null;
38
+ }
39
+
40
+ function findBin(projectRoot) {
41
+ const local = join(projectRoot, 'node_modules', '.bin', 'opentology');
42
+ if (existsSync(local)) return { bin: local, args: [] };
43
+ return { bin: 'npx', args: ['opentology'] };
44
+ }
45
+
46
+ function run(projectRoot, cmdArgs) {
47
+ const { bin, args } = findBin(projectRoot);
48
+ return execFileSync(bin, [...args, ...cmdArgs], {
49
+ cwd: projectRoot,
50
+ timeout: TIMEOUT,
51
+ encoding: 'utf-8',
52
+ stdio: ['pipe', 'pipe', 'pipe'],
53
+ });
54
+ }
55
+
56
+ function extractKeywords(text) {
57
+ const keywords = [];
58
+
59
+ // Step 1: Split mixed Korean+English tokens (e.g. "persistGraph에서" → "persistGraph", "에서")
60
+ const raw = text.replace(/[\\n\\r]/g, ' ');
61
+ const mixedSplit = raw.replace(/([a-zA-Z0-9_./]+)([\\uAC00-\\uD7AF\\u3130-\\u318F]+)/g, '$1 $2')
62
+ .replace(/([\\uAC00-\\uD7AF\\u3130-\\u318F]+)([a-zA-Z0-9_./]+)/g, '$1 $2');
63
+
64
+ const tokens = mixedSplit
65
+ .split(/[\\s,.:;!?()\\[\\]{}'"]+/)
66
+ .filter(Boolean);
67
+
68
+ for (const token of tokens) {
69
+ const lower = token.toLowerCase();
70
+ if (STOP_WORDS.has(lower)) continue;
71
+ // English identifiers (camelCase, snake_case, PascalCase) — 4+ chars
72
+ if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(token) && token.length >= 4) {
73
+ keywords.push(lower);
74
+ }
75
+ // Korean content words (3+ chars to skip particles like 에러가→에러)
76
+ if (/[\\uAC00-\\uD7AF]/.test(token) && token.length >= 3 && !STOP_WORDS.has(token)) {
77
+ keywords.push(token);
78
+ }
79
+ // File paths, module references, Error class names
80
+ if (token.includes('/') || token.includes('.') || /Error$/.test(token)) {
81
+ keywords.push(token);
82
+ }
83
+ }
84
+
85
+ // Deduplicate and limit
86
+ return [...new Set(keywords)].slice(0, 5);
87
+ }
88
+
89
+ try {
90
+ const projectRoot = findProjectRoot(process.cwd());
91
+ if (!projectRoot) process.exit(0);
92
+
93
+ // Read user prompt from stdin
94
+ let input = '';
95
+ try {
96
+ input = readFileSync('/dev/stdin', 'utf-8');
97
+ } catch {
98
+ process.exit(0);
99
+ }
100
+
101
+ let userMessage = '';
102
+ try {
103
+ const parsed = JSON.parse(input);
104
+ userMessage = parsed.user_prompt || parsed.message || '';
105
+ } catch {
106
+ // Plain text input
107
+ userMessage = input;
108
+ }
109
+
110
+ if (!userMessage || userMessage.length < 5) process.exit(0);
111
+
112
+ const keywords = extractKeywords(userMessage);
113
+ if (keywords.length === 0) process.exit(0);
114
+
115
+ const raw = run(projectRoot, ['context', 'search', '--keywords', keywords.join(','), '--format', 'json']);
116
+ const data = JSON.parse(raw);
117
+
118
+ if (!data.results || data.results.length === 0) process.exit(0);
119
+
120
+ const lines = [];
121
+ lines.push('[Graph Context] Related to your query:');
122
+ for (const r of data.results.slice(0, 5)) {
123
+ const tag = r.type.replace('https://opentology.dev/vocab#', '');
124
+ let line = \`- [\${tag}] \${r.title}\`;
125
+ if (r.status) line += \` (\${r.status})\`;
126
+ if (r.date) line += \` [\${r.date}]\`;
127
+ lines.push(line);
128
+ if (r.solution) lines.push(\` Solution: \${r.solution}\`);
129
+ if (r.reason) lines.push(\` Reason: \${r.reason}\`);
130
+ if (r.body && r.body.length <= 200) lines.push(\` \${r.body}\`);
131
+ }
132
+
133
+ console.log(lines.join('\\n'));
134
+ } catch {
135
+ // Silent exit — do not block prompt submission
136
+ process.exit(0);
137
+ }
138
+ `;
139
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentology",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Ontology-powered project memory for AI coding assistants — your codebase as a knowledge graph",
5
5
  "type": "module",
6
6
  "bin": {