bluera-knowledge 0.31.0 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +23 -0
- package/.mcp.json +13 -0
- package/CHANGELOG.md +42 -0
- package/NOTICE +47 -0
- package/README.md +2 -2
- package/bun.lock +1978 -0
- package/dist/{chunk-B335UOU7.js → chunk-3TB7TDVF.js} +24 -3
- package/dist/chunk-3TB7TDVF.js.map +1 -0
- package/dist/{chunk-KCI4U6FH.js → chunk-KDZDLJUY.js} +2 -2
- package/dist/{chunk-AEXFPA57.js → chunk-YDTTD53Y.js} +158 -26
- package/dist/chunk-YDTTD53Y.js.map +1 -0
- package/dist/index.js +3 -3
- package/dist/mcp/bootstrap.js +10 -0
- package/dist/mcp/bootstrap.js.map +1 -1
- package/dist/mcp/server.d.ts +5 -3
- package/dist/mcp/server.js +2 -2
- package/dist/workers/background-worker-cli.js +2 -2
- package/hooks/check-ready.sh +109 -0
- package/hooks/hooks.json +97 -0
- package/hooks/job-status-hook.sh +51 -0
- package/hooks/posttooluse-bk-reminder.py +126 -0
- package/hooks/posttooluse-web-research.py +209 -0
- package/hooks/posttooluse-websearch-bk.py +158 -0
- package/hooks/pretooluse-bk-suggest.py +296 -0
- package/hooks/skill-activation.py +221 -0
- package/hooks/skill-rules.json +131 -0
- package/package.json +9 -2
- package/scripts/CLAUDE.md +65 -0
- package/scripts/auto-setup.sh +65 -0
- package/scripts/bench-regression.sh +345 -0
- package/scripts/dev.sh +16 -0
- package/scripts/doctor.sh +103 -0
- package/scripts/download-models.ts +188 -0
- package/scripts/export-web-store.ts +142 -0
- package/scripts/lib/mock-server.sh +70 -0
- package/scripts/mcp-wrapper.sh +91 -0
- package/scripts/setup.sh +224 -0
- package/scripts/statusline-module.sh +29 -0
- package/scripts/test-mcp-dev.js +260 -0
- package/scripts/validate-local.sh +412 -0
- package/scripts/validate-npm-release.sh +406 -0
- package/skills/add-folder/SKILL.md +48 -0
- package/skills/add-repo/SKILL.md +50 -0
- package/skills/advanced-workflows/SKILL.md +273 -0
- package/skills/cancel/SKILL.md +63 -0
- package/skills/check-status/SKILL.md +130 -0
- package/skills/crawl/SKILL.md +61 -0
- package/skills/doctor/SKILL.md +27 -0
- package/skills/eval/SKILL.md +222 -0
- package/skills/health/SKILL.md +72 -0
- package/skills/index/SKILL.md +48 -0
- package/skills/knowledge-search/SKILL.md +110 -0
- package/skills/remove-store/SKILL.md +52 -0
- package/skills/search/SKILL.md +80 -0
- package/skills/search/search.sh +63 -0
- package/skills/search-optimization/SKILL.md +199 -0
- package/skills/search-optimization/references/mistakes.md +21 -0
- package/skills/search-optimization/references/strategies.md +80 -0
- package/skills/skill-activation/SKILL.md +131 -0
- package/skills/statusline/SKILL.md +19 -0
- package/skills/store-lifecycle/SKILL.md +470 -0
- package/skills/stores/SKILL.md +54 -0
- package/skills/suggest/SKILL.md +118 -0
- package/skills/sync/SKILL.md +96 -0
- package/skills/test-plugin/SKILL.md +547 -0
- package/skills/uninstall/SKILL.md +65 -0
- package/skills/when-to-query/SKILL.md +160 -0
- package/dist/chunk-AEXFPA57.js.map +0 -1
- package/dist/chunk-B335UOU7.js.map +0 -1
- /package/dist/{chunk-KCI4U6FH.js.map → chunk-KDZDLJUY.js.map} +0 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
PostToolUse hook for bluera-knowledge plugin.
|
|
4
|
+
|
|
5
|
+
Fires after WebSearch tool calls. Checks if the search query relates to a
|
|
6
|
+
library that is already indexed in BK, and if so reminds Claude to use BK
|
|
7
|
+
search instead of relying solely on web results.
|
|
8
|
+
|
|
9
|
+
Deduplicates per-project so the same store only triggers once per hour.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# How long (seconds) before a seen store can trigger again
|
|
21
|
+
DEDUP_TTL = 3600 # 1 hour
|
|
22
|
+
|
|
23
|
+
# Minimum store name length to avoid false positives on short names
|
|
24
|
+
MIN_STORE_NAME_LEN = 3
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_stores_path() -> Path | None:
|
|
28
|
+
"""Get path to stores.json from project root."""
|
|
29
|
+
project_root = os.environ.get("PROJECT_ROOT") or os.environ.get("PWD")
|
|
30
|
+
if not project_root:
|
|
31
|
+
return None
|
|
32
|
+
return Path(project_root) / ".bluera" / "bluera-knowledge" / "data" / "stores.json"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_stores() -> list[dict]:
|
|
36
|
+
"""Load stores from stores.json. Returns empty list on error."""
|
|
37
|
+
stores_path = get_stores_path()
|
|
38
|
+
if stores_path is None or not stores_path.exists():
|
|
39
|
+
return []
|
|
40
|
+
try:
|
|
41
|
+
with open(stores_path, encoding="utf-8") as f:
|
|
42
|
+
data = json.load(f)
|
|
43
|
+
if isinstance(data, dict):
|
|
44
|
+
stores = data.get("stores", [])
|
|
45
|
+
return stores if isinstance(stores, list) else []
|
|
46
|
+
return data if isinstance(data, list) else []
|
|
47
|
+
except (json.JSONDecodeError, IOError, OSError):
|
|
48
|
+
return []
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _dedup_path(project_root: str) -> str:
|
|
52
|
+
"""Return the path to the per-project dedup file."""
|
|
53
|
+
h = hashlib.md5(project_root.encode()).hexdigest()[:8]
|
|
54
|
+
return f"/tmp/bk-websearch-{h}.json"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_already_seen(store_name: str, project_root: str) -> bool:
|
|
58
|
+
"""Check if this store was already flagged within the TTL."""
|
|
59
|
+
try:
|
|
60
|
+
path = _dedup_path(project_root)
|
|
61
|
+
with open(path) as f:
|
|
62
|
+
seen: dict[str, float] = json.load(f)
|
|
63
|
+
ts = seen.get(store_name, 0)
|
|
64
|
+
return (time.time() - ts) < DEDUP_TTL
|
|
65
|
+
except Exception:
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def mark_seen(store_name: str, project_root: str) -> None:
|
|
70
|
+
"""Record this store as seen, pruning expired entries."""
|
|
71
|
+
try:
|
|
72
|
+
path = _dedup_path(project_root)
|
|
73
|
+
now = time.time()
|
|
74
|
+
seen: dict[str, float] = {}
|
|
75
|
+
try:
|
|
76
|
+
with open(path) as f:
|
|
77
|
+
seen = json.load(f)
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
seen = {k: v for k, v in seen.items() if (now - v) < DEDUP_TTL}
|
|
81
|
+
seen[store_name] = now
|
|
82
|
+
with open(path, "w") as f:
|
|
83
|
+
json.dump(seen, f)
|
|
84
|
+
except Exception:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def find_matching_stores(query: str, stores: list[dict]) -> list[str]:
|
|
89
|
+
"""Find store names that appear in the search query."""
|
|
90
|
+
query_lower = query.lower()
|
|
91
|
+
matched = []
|
|
92
|
+
for store in stores:
|
|
93
|
+
name = store.get("name", "")
|
|
94
|
+
if len(name) < MIN_STORE_NAME_LEN:
|
|
95
|
+
continue
|
|
96
|
+
if name.lower() in query_lower:
|
|
97
|
+
matched.append(name)
|
|
98
|
+
return matched
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def main() -> int:
|
|
102
|
+
try:
|
|
103
|
+
stdin_data = sys.stdin.read()
|
|
104
|
+
if not stdin_data.strip():
|
|
105
|
+
return 0
|
|
106
|
+
hook_input = json.loads(stdin_data)
|
|
107
|
+
|
|
108
|
+
tool_name = hook_input.get("tool_name", "")
|
|
109
|
+
if tool_name != "WebSearch":
|
|
110
|
+
return 0
|
|
111
|
+
|
|
112
|
+
tool_input = hook_input.get("tool_input", {})
|
|
113
|
+
query = tool_input.get("query", "")
|
|
114
|
+
if not query:
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
stores = load_stores()
|
|
118
|
+
if not stores:
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
matched = find_matching_stores(query, stores)
|
|
122
|
+
if not matched:
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
project_root = os.environ.get("PROJECT_ROOT", os.environ.get("PWD", ""))
|
|
126
|
+
|
|
127
|
+
# Filter out already-seen stores
|
|
128
|
+
unseen = [s for s in matched if not is_already_seen(s, project_root)]
|
|
129
|
+
if not unseen:
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
for store_name in unseen:
|
|
133
|
+
mark_seen(store_name, project_root)
|
|
134
|
+
|
|
135
|
+
store_list = ", ".join(unseen)
|
|
136
|
+
message = (
|
|
137
|
+
f"You searched the web for a topic related to indexed libraries: {store_list}\n"
|
|
138
|
+
f"You have authoritative source code indexed in Bluera Knowledge.\n"
|
|
139
|
+
f"Use mcp__bluera-knowledge__search to get definitive answers from the actual source:\n"
|
|
140
|
+
f" mcp__bluera-knowledge__search(query='{query}', intent='find-implementation')\n"
|
|
141
|
+
f"This is faster and more accurate than web results."
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
output = {
|
|
145
|
+
"hookSpecificOutput": {
|
|
146
|
+
"hookEventName": "PostToolUse",
|
|
147
|
+
"additionalContext": message,
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
print(json.dumps(output))
|
|
151
|
+
return 0
|
|
152
|
+
|
|
153
|
+
except Exception:
|
|
154
|
+
return 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
if __name__ == "__main__":
|
|
158
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
PreToolUse hook for bluera-knowledge plugin.
|
|
4
|
+
|
|
5
|
+
Fires before Claude reads/greps in dependency directories.
|
|
6
|
+
- If library IS indexed in BK: BLOCKS the read and suggests BK search
|
|
7
|
+
- If library NOT indexed: suggests BK but allows the read
|
|
8
|
+
|
|
9
|
+
Escape hatch: Set BK_ALLOW_DIRECT_READ=1 env var to bypass all blocking.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import TypedDict
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class StoreDict(TypedDict, total=False):
|
|
22
|
+
"""Store structure from stores.json."""
|
|
23
|
+
|
|
24
|
+
name: str
|
|
25
|
+
tags: list[str] | None
|
|
26
|
+
url: str | None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ToolInputDict(TypedDict, total=False):
|
|
30
|
+
"""Tool input structure from hook."""
|
|
31
|
+
|
|
32
|
+
path: str # For Grep
|
|
33
|
+
file_path: str # For Read
|
|
34
|
+
|
|
35
|
+
# Configure logging to stderr (stdout is for hook output)
|
|
36
|
+
logging.basicConfig(
|
|
37
|
+
level=logging.DEBUG if os.environ.get("BK_DEBUG") else logging.WARNING,
|
|
38
|
+
format="%(name)s: %(message)s",
|
|
39
|
+
stream=sys.stderr,
|
|
40
|
+
)
|
|
41
|
+
logger = logging.getLogger("bk-suggest")
|
|
42
|
+
|
|
43
|
+
# Dependency path patterns with boundary markers to avoid false positives
|
|
44
|
+
# like "my-node_modules-backup/"
|
|
45
|
+
DEPENDENCY_PATTERNS = (
|
|
46
|
+
"/node_modules/",
|
|
47
|
+
"/vendor/",
|
|
48
|
+
"/site-packages/",
|
|
49
|
+
"/.venv/",
|
|
50
|
+
"/venv/",
|
|
51
|
+
"/bower_components/",
|
|
52
|
+
"/.npm/",
|
|
53
|
+
"/.cargo/registry/",
|
|
54
|
+
"/go/pkg/mod/",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Blocking criteria - avoid false positives on ultra-common names
|
|
58
|
+
MIN_NAME_LENGTH = 3
|
|
59
|
+
COMMON_NAME_DENYLIST = frozenset([
|
|
60
|
+
"fs", "os", "core", "util", "utils", "lib", "src", "app", "api",
|
|
61
|
+
"test", "tests", "spec", "dist", "build", "bin", "pkg", "mod",
|
|
62
|
+
])
|
|
63
|
+
|
|
64
|
+
# Module-level cache for stores.json
|
|
65
|
+
_stores_cache: list[StoreDict] = []
|
|
66
|
+
_stores_mtime: float = 0.0
|
|
67
|
+
_stores_path: Path | None = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def is_dependency_path(path: str) -> bool:
|
|
71
|
+
"""Return True only if path is inside a dependency directory.
|
|
72
|
+
|
|
73
|
+
Uses boundary markers to avoid false positives like 'my-node_modules-backup/'.
|
|
74
|
+
"""
|
|
75
|
+
# Normalize path separators and ensure leading slash for boundary matching
|
|
76
|
+
normalized = "/" + path.replace("\\", "/").lower()
|
|
77
|
+
return any(pattern in normalized for pattern in DEPENDENCY_PATTERNS)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def extract_library_name(path: str) -> str | None:
|
|
81
|
+
"""Extract library name from dependency path."""
|
|
82
|
+
# node_modules/package-name/... or node_modules/@scope/package/...
|
|
83
|
+
match = re.search(r"node_modules/(@[^/]+/[^/]+|[^/]+)", path)
|
|
84
|
+
if match:
|
|
85
|
+
return match.group(1)
|
|
86
|
+
|
|
87
|
+
# site-packages/package_name/...
|
|
88
|
+
match = re.search(r"site-packages/([^/]+)", path)
|
|
89
|
+
if match:
|
|
90
|
+
return match.group(1)
|
|
91
|
+
|
|
92
|
+
# vendor/package/...
|
|
93
|
+
match = re.search(r"vendor/([^/]+)", path)
|
|
94
|
+
if match:
|
|
95
|
+
return match.group(1)
|
|
96
|
+
|
|
97
|
+
# .cargo/registry/.../package-name-version/...
|
|
98
|
+
match = re.search(r"\.cargo/registry/[^/]+/([^/]+)-\d", path)
|
|
99
|
+
if match:
|
|
100
|
+
return match.group(1)
|
|
101
|
+
|
|
102
|
+
# go/pkg/mod/package@version/...
|
|
103
|
+
match = re.search(r"go/pkg/mod/([^@]+)@", path)
|
|
104
|
+
if match:
|
|
105
|
+
return match.group(1)
|
|
106
|
+
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def normalize_name(name: str) -> str:
|
|
111
|
+
"""Normalize library name for matching.
|
|
112
|
+
|
|
113
|
+
- Strips scope: @scope/pkg -> pkg
|
|
114
|
+
- Removes hyphens and underscores
|
|
115
|
+
- Lowercases
|
|
116
|
+
"""
|
|
117
|
+
# Strip scope: @scope/pkg -> pkg
|
|
118
|
+
if "/" in name:
|
|
119
|
+
name = name.split("/")[-1]
|
|
120
|
+
# Remove hyphens, underscores, lowercase
|
|
121
|
+
return name.replace("-", "").replace("_", "").lower()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def extract_repo_name(url: str | None) -> str | None:
|
|
125
|
+
"""Extract repo name from URL like github.com/owner/repo."""
|
|
126
|
+
if not url:
|
|
127
|
+
return None
|
|
128
|
+
# github.com/owner/repo -> repo
|
|
129
|
+
# github.com/owner/repo.git -> repo
|
|
130
|
+
match = re.search(r"/([^/]+?)(?:\.git)?$", url)
|
|
131
|
+
return match.group(1) if match else None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def get_stores_path() -> Path | None:
|
|
135
|
+
"""Get path to stores.json from project root."""
|
|
136
|
+
project_root = os.environ.get("PROJECT_ROOT") or os.environ.get("PWD")
|
|
137
|
+
if not project_root:
|
|
138
|
+
return None
|
|
139
|
+
return Path(project_root) / ".bluera" / "bluera-knowledge" / "data" / "stores.json"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def load_stores(stores_path: Path) -> list[StoreDict]:
|
|
143
|
+
"""Load stores from stores.json. Returns empty list on error."""
|
|
144
|
+
if not stores_path.exists():
|
|
145
|
+
logger.debug("stores.json not found: %s", stores_path)
|
|
146
|
+
return []
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
with open(stores_path, encoding="utf-8") as f:
|
|
150
|
+
data = json.load(f)
|
|
151
|
+
# Registry format: {"version": 1, "stores": [...]}
|
|
152
|
+
if isinstance(data, dict):
|
|
153
|
+
stores = data.get("stores", [])
|
|
154
|
+
return stores if isinstance(stores, list) else []
|
|
155
|
+
# Direct list format: [...]
|
|
156
|
+
return data if isinstance(data, list) else []
|
|
157
|
+
except (json.JSONDecodeError, IOError, OSError) as e:
|
|
158
|
+
logger.debug("Failed to read stores.json: %s", e)
|
|
159
|
+
return []
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def get_cached_stores() -> list[StoreDict]:
|
|
163
|
+
"""Get stores with mtime-based cache invalidation."""
|
|
164
|
+
global _stores_cache, _stores_mtime, _stores_path
|
|
165
|
+
|
|
166
|
+
if _stores_path is None:
|
|
167
|
+
_stores_path = get_stores_path()
|
|
168
|
+
if _stores_path is None:
|
|
169
|
+
logger.debug("No project root found, skipping store check")
|
|
170
|
+
return []
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
current_mtime = _stores_path.stat().st_mtime
|
|
174
|
+
if current_mtime != _stores_mtime:
|
|
175
|
+
logger.debug(
|
|
176
|
+
"Cache refresh: stores.json mtime changed (%.3f -> %.3f)",
|
|
177
|
+
_stores_mtime,
|
|
178
|
+
current_mtime,
|
|
179
|
+
)
|
|
180
|
+
_stores_cache = load_stores(_stores_path)
|
|
181
|
+
_stores_mtime = current_mtime
|
|
182
|
+
except (OSError, FileNotFoundError):
|
|
183
|
+
_stores_cache = []
|
|
184
|
+
_stores_mtime = 0.0
|
|
185
|
+
|
|
186
|
+
return _stores_cache
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def library_is_indexed(lib_name: str, stores: list[StoreDict]) -> str | None:
|
|
190
|
+
"""Return matching store name if library is indexed, None otherwise.
|
|
191
|
+
|
|
192
|
+
Denylist is soft - if an EXACT store name match exists,
|
|
193
|
+
the denylist is overridden (user explicitly created a store for that name).
|
|
194
|
+
Tag/URL fuzzy matching is skipped for denylisted names.
|
|
195
|
+
"""
|
|
196
|
+
if len(lib_name) < MIN_NAME_LENGTH:
|
|
197
|
+
return None # Too short, skip
|
|
198
|
+
|
|
199
|
+
normalized = normalize_name(lib_name)
|
|
200
|
+
is_denylisted = lib_name.lower() in COMMON_NAME_DENYLIST
|
|
201
|
+
|
|
202
|
+
for store in stores:
|
|
203
|
+
store_name = store.get("name", "")
|
|
204
|
+
|
|
205
|
+
# Check store name - exact match overrides denylist
|
|
206
|
+
if normalize_name(store_name) == normalized:
|
|
207
|
+
return store_name
|
|
208
|
+
|
|
209
|
+
# If denylisted, skip tag/URL fuzzy matching (only exact store name counts)
|
|
210
|
+
if is_denylisted:
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
# Check tags (only if not denylisted)
|
|
214
|
+
for tag in store.get("tags") or []:
|
|
215
|
+
if normalize_name(tag) == normalized:
|
|
216
|
+
return store_name
|
|
217
|
+
|
|
218
|
+
# Check repo URL (only if not denylisted)
|
|
219
|
+
repo_name = extract_repo_name(store.get("url"))
|
|
220
|
+
if repo_name and normalize_name(repo_name) == normalized:
|
|
221
|
+
return store_name
|
|
222
|
+
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def get_path_from_input(tool_name: str, tool_input: ToolInputDict) -> str | None:
|
|
227
|
+
"""Extract the path being accessed from tool input."""
|
|
228
|
+
if tool_name == "Grep":
|
|
229
|
+
return tool_input.get("path", "")
|
|
230
|
+
elif tool_name == "Read":
|
|
231
|
+
return tool_input.get("file_path", "")
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def main() -> int:
|
|
236
|
+
try:
|
|
237
|
+
# Escape hatch: bypass all blocking if env var is set
|
|
238
|
+
if os.environ.get("BK_ALLOW_DIRECT_READ"):
|
|
239
|
+
logger.debug("Escape hatch: BK_ALLOW_DIRECT_READ env var set, bypassing")
|
|
240
|
+
return 0
|
|
241
|
+
|
|
242
|
+
stdin_data = sys.stdin.read()
|
|
243
|
+
if not stdin_data.strip():
|
|
244
|
+
return 0
|
|
245
|
+
hook_input = json.loads(stdin_data)
|
|
246
|
+
|
|
247
|
+
tool_name = hook_input.get("tool_name", "")
|
|
248
|
+
tool_input = hook_input.get("tool_input", {})
|
|
249
|
+
|
|
250
|
+
path = get_path_from_input(tool_name, tool_input)
|
|
251
|
+
if not path:
|
|
252
|
+
return 0
|
|
253
|
+
|
|
254
|
+
# Fast path: skip if not a dependency path (with boundary checking)
|
|
255
|
+
if not is_dependency_path(path):
|
|
256
|
+
return 0
|
|
257
|
+
|
|
258
|
+
lib_name = extract_library_name(path)
|
|
259
|
+
if not lib_name:
|
|
260
|
+
return 0
|
|
261
|
+
|
|
262
|
+
# Check if library is indexed in BK
|
|
263
|
+
stores = get_cached_stores()
|
|
264
|
+
matching_store = library_is_indexed(lib_name, stores)
|
|
265
|
+
|
|
266
|
+
if matching_store:
|
|
267
|
+
# BLOCK: Library is indexed, force BK usage
|
|
268
|
+
reason = f"'{lib_name}' is indexed in BK store '{matching_store}'. Use BK search tool instead."
|
|
269
|
+
output = {
|
|
270
|
+
"hookSpecificOutput": {
|
|
271
|
+
"hookEventName": "PreToolUse",
|
|
272
|
+
"permissionDecision": "deny",
|
|
273
|
+
"reasonForDecision": reason,
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
print(json.dumps(output))
|
|
277
|
+
else:
|
|
278
|
+
# SUGGEST: Library not indexed, allow but suggest
|
|
279
|
+
suggestion = f"TIP: '{lib_name}' isn't indexed in BK. Consider /bluera-knowledge:add-repo after this task."
|
|
280
|
+
output = {
|
|
281
|
+
"hookSpecificOutput": {
|
|
282
|
+
"hookEventName": "PreToolUse",
|
|
283
|
+
"additionalContext": suggestion,
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
print(json.dumps(output))
|
|
287
|
+
|
|
288
|
+
return 0
|
|
289
|
+
|
|
290
|
+
except Exception as e:
|
|
291
|
+
logger.debug("Hook error, allowing: %s", e)
|
|
292
|
+
return 0 # Never block on unexpected errors
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
if __name__ == "__main__":
|
|
296
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Skill activation hook for bluera-knowledge plugin.
|
|
4
|
+
Matches user prompts against skill rules and injects activation reminders.
|
|
5
|
+
|
|
6
|
+
Runs on UserPromptSubmit to detect users who would benefit from learning
|
|
7
|
+
about BK skills, while excluding users who already know BK terminology.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
DEFAULT_CONFIG: dict[str, Any] = {
|
|
18
|
+
"enabled": True,
|
|
19
|
+
"threshold": 1,
|
|
20
|
+
"skills": {
|
|
21
|
+
"knowledge-search": True,
|
|
22
|
+
"when-to-query": True,
|
|
23
|
+
"search-optimization": True,
|
|
24
|
+
"advanced-workflows": True,
|
|
25
|
+
"store-lifecycle": True,
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_project_root() -> Path | None:
|
|
31
|
+
"""Get project root from environment variables (per-repo config)."""
|
|
32
|
+
# Check environment variables in priority order
|
|
33
|
+
for env_var in ["PROJECT_ROOT", "PWD"]:
|
|
34
|
+
value = os.environ.get(env_var, "")
|
|
35
|
+
if value:
|
|
36
|
+
return Path(value)
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_config_path() -> Path | None:
|
|
41
|
+
"""Get per-repo config path for skill activation."""
|
|
42
|
+
project_root = get_project_root()
|
|
43
|
+
if project_root is None:
|
|
44
|
+
return None
|
|
45
|
+
return project_root / ".bluera" / "bluera-knowledge" / "skill-activation.json"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_config() -> dict[str, Any]:
|
|
49
|
+
"""Load skill activation configuration from per-repo path."""
|
|
50
|
+
config_path = get_config_path()
|
|
51
|
+
if config_path is None or not config_path.exists():
|
|
52
|
+
return DEFAULT_CONFIG.copy()
|
|
53
|
+
try:
|
|
54
|
+
with open(config_path, encoding="utf-8") as f:
|
|
55
|
+
return json.load(f)
|
|
56
|
+
except (json.JSONDecodeError, IOError):
|
|
57
|
+
return DEFAULT_CONFIG.copy()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load_rules(plugin_root: Path) -> dict[str, Any]:
|
|
61
|
+
"""Load skill rules from plugin hooks directory."""
|
|
62
|
+
rules_path = plugin_root / "hooks" / "skill-rules.json"
|
|
63
|
+
if not rules_path.exists():
|
|
64
|
+
return {"skills": [], "threshold": 1, "globalExclusions": []}
|
|
65
|
+
with open(rules_path, encoding="utf-8") as f:
|
|
66
|
+
return json.load(f)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def matches_condition(prompt: str, condition: dict[str, Any]) -> bool:
|
|
70
|
+
"""Check if prompt matches a single condition (keyword or regex)."""
|
|
71
|
+
prompt_lower = prompt.lower()
|
|
72
|
+
if "keyword" in condition:
|
|
73
|
+
return condition["keyword"].lower() in prompt_lower
|
|
74
|
+
if "regex" in condition:
|
|
75
|
+
return bool(re.search(condition["regex"], prompt, flags=re.IGNORECASE))
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def check_exclusions(
|
|
80
|
+
prompt: str, exclusions: list[dict[str, Any]]
|
|
81
|
+
) -> bool:
|
|
82
|
+
"""Check if any exclusion pattern matches. Returns True if excluded."""
|
|
83
|
+
for exc in exclusions:
|
|
84
|
+
if matches_condition(prompt, exc):
|
|
85
|
+
return True
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def score_skill(
|
|
90
|
+
prompt: str, skill: dict[str, Any]
|
|
91
|
+
) -> tuple[int, list[str]]:
|
|
92
|
+
"""Score a skill against the user prompt. Returns (score, reasons)."""
|
|
93
|
+
reasons: list[str] = []
|
|
94
|
+
score = 0
|
|
95
|
+
|
|
96
|
+
# Check skill-specific exclusions first
|
|
97
|
+
if check_exclusions(prompt, skill.get("exclusions", [])):
|
|
98
|
+
return 0, []
|
|
99
|
+
|
|
100
|
+
for trigger in skill.get("triggers", []):
|
|
101
|
+
if matches_condition(prompt, trigger):
|
|
102
|
+
weight = trigger.get("weight", 1)
|
|
103
|
+
score += weight
|
|
104
|
+
if "keyword" in trigger:
|
|
105
|
+
reasons.append(f'keyword "{trigger["keyword"]}"')
|
|
106
|
+
elif "regex" in trigger:
|
|
107
|
+
reasons.append(f'pattern match')
|
|
108
|
+
|
|
109
|
+
return score, reasons
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def generate_reminder(
|
|
113
|
+
matches: list[tuple[str, int, list[str], str]]
|
|
114
|
+
) -> str:
|
|
115
|
+
"""Generate assertive skill activation reminder using forced evaluation pattern."""
|
|
116
|
+
lines = [
|
|
117
|
+
"CRITICAL DIRECTIVE: Library/Dependency Question Detected",
|
|
118
|
+
"",
|
|
119
|
+
"BEFORE answering, you MUST:",
|
|
120
|
+
"",
|
|
121
|
+
"1. CHECK if a bluera-knowledge store exists for this library:",
|
|
122
|
+
" → Use mcp__bluera-knowledge__execute(command='stores') to list indexed libraries",
|
|
123
|
+
"",
|
|
124
|
+
"2. IF INDEXED: Query the indexed source FIRST:",
|
|
125
|
+
" → mcp__bluera-knowledge__search(query='your question', intent='find-implementation')",
|
|
126
|
+
" → This is FASTER and more accurate than grepping node_modules/vendor",
|
|
127
|
+
"",
|
|
128
|
+
"3. IF NOT INDEXED: Suggest adding it:",
|
|
129
|
+
" → Tell the user: '/bluera-knowledge:add-repo to index this library for future queries'",
|
|
130
|
+
"",
|
|
131
|
+
"MATCHED SKILLS (invoke for guidance):",
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
for name, score, reasons, description in matches:
|
|
135
|
+
lines.append(f" • Skill(skill='bluera-knowledge:{name}') - {description}")
|
|
136
|
+
lines.append(f" Triggered by: {', '.join(reasons[:2])}")
|
|
137
|
+
|
|
138
|
+
lines.append("")
|
|
139
|
+
lines.append("WHY THIS MATTERS:")
|
|
140
|
+
lines.append(" - Grepping node_modules is SLOW (reads thousands of files)")
|
|
141
|
+
lines.append(" - Indexed search returns pre-chunked, semantic results")
|
|
142
|
+
lines.append(" - Token cost is 10-100x lower with indexed search")
|
|
143
|
+
lines.append("")
|
|
144
|
+
lines.append("FAILURE TO CONSULT INDEXED SOURCES = SUBOPTIMAL RESPONSE")
|
|
145
|
+
|
|
146
|
+
return "\n".join(lines)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def main() -> int:
|
|
150
|
+
# Load configuration
|
|
151
|
+
config = load_config()
|
|
152
|
+
|
|
153
|
+
# Check if skill activation is enabled
|
|
154
|
+
if not config.get("enabled", True):
|
|
155
|
+
return 0
|
|
156
|
+
|
|
157
|
+
# Get plugin root from environment
|
|
158
|
+
plugin_root_env = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
|
|
159
|
+
if not plugin_root_env:
|
|
160
|
+
return 0
|
|
161
|
+
plugin_root = Path(plugin_root_env)
|
|
162
|
+
|
|
163
|
+
# Read hook input from stdin
|
|
164
|
+
try:
|
|
165
|
+
stdin_data = sys.stdin.read()
|
|
166
|
+
if not stdin_data.strip():
|
|
167
|
+
return 0
|
|
168
|
+
hook_input = json.loads(stdin_data)
|
|
169
|
+
except json.JSONDecodeError:
|
|
170
|
+
return 0
|
|
171
|
+
|
|
172
|
+
prompt = hook_input.get("prompt", "")
|
|
173
|
+
if not prompt.strip():
|
|
174
|
+
return 0
|
|
175
|
+
|
|
176
|
+
# Load rules
|
|
177
|
+
rules = load_rules(plugin_root)
|
|
178
|
+
|
|
179
|
+
# Check global exclusions first
|
|
180
|
+
if check_exclusions(prompt, rules.get("globalExclusions", [])):
|
|
181
|
+
return 0
|
|
182
|
+
|
|
183
|
+
threshold = config.get("threshold", rules.get("threshold", 1))
|
|
184
|
+
enabled_skills = config.get("skills", {})
|
|
185
|
+
|
|
186
|
+
# Score each skill
|
|
187
|
+
matches: list[tuple[str, int, list[str], str]] = []
|
|
188
|
+
|
|
189
|
+
for skill in rules.get("skills", []):
|
|
190
|
+
name = skill["name"]
|
|
191
|
+
|
|
192
|
+
# Skip disabled skills
|
|
193
|
+
if not enabled_skills.get(name, True):
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
score, reasons = score_skill(prompt, skill)
|
|
197
|
+
if score >= threshold:
|
|
198
|
+
matches.append((name, score, reasons, skill.get("description", "")))
|
|
199
|
+
|
|
200
|
+
# No matches - silent exit
|
|
201
|
+
if not matches:
|
|
202
|
+
return 0
|
|
203
|
+
|
|
204
|
+
# Sort by score (highest first)
|
|
205
|
+
matches.sort(key=lambda t: t[1], reverse=True)
|
|
206
|
+
|
|
207
|
+
# Generate and output the reminder using proper JSON format
|
|
208
|
+
reminder = generate_reminder(matches)
|
|
209
|
+
output = {
|
|
210
|
+
"hookSpecificOutput": {
|
|
211
|
+
"hookEventName": "UserPromptSubmit",
|
|
212
|
+
"additionalContext": reminder,
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
print(json.dumps(output))
|
|
216
|
+
|
|
217
|
+
return 0
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
if __name__ == "__main__":
|
|
221
|
+
raise SystemExit(main())
|