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 +6 -0
- package/dist/commands/context.js +158 -6
- package/dist/index.js +0 -30
- package/dist/lib/doctor.d.ts +6 -0
- package/dist/lib/doctor.js +99 -0
- package/dist/lib/store-factory.js +0 -4
- package/dist/mcp/server.d.ts +27 -0
- package/dist/mcp/server.js +122 -1
- package/dist/templates/claude-md-context.js +4 -1
- package/dist/templates/post-error-hook.d.ts +1 -0
- package/dist/templates/post-error-hook.js +146 -0
- package/dist/templates/user-prompt-hook.d.ts +1 -0
- package/dist/templates/user-prompt-hook.js +139 -0
- package/package.json +1 -1
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
|
+
[](https://www.npmjs.com/package/opentology) [](LICENSE) [](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
|
```
|
package/dist/commands/context.js
CHANGED
|
@@ -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
|
|
215
|
+
// Ask about smart hooks (PreToolUse, UserPromptSubmit, PostToolUse)
|
|
194
216
|
console.log('');
|
|
195
|
-
const
|
|
196
|
-
pc.dim('(auto-
|
|
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 (
|
|
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
|
|
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
|
|
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,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();
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/mcp/server.js
CHANGED
|
@@ -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 '../
|
|
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
|
|
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
|
+
}
|