tylor-mcp 1.0.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.
Files changed (101) hide show
  1. package/.aws-setup.sh +25 -0
  2. package/.claude-plugin/plugin.json +22 -0
  3. package/.mcp.json +12 -0
  4. package/AGENTS.md +93 -0
  5. package/CLAUDE.md +99 -0
  6. package/CLAUDE_PLATFORM_AWS_SETUP.md +105 -0
  7. package/LICENSE +21 -0
  8. package/README.md +146 -0
  9. package/assets/tylor_logo.png +0 -0
  10. package/assets/tylor_threads_concept.png +0 -0
  11. package/bin/tylor.js +23 -0
  12. package/hooks/kill-thread-trigger.sh +7 -0
  13. package/hooks/post-tool-use-code-index.sh +7 -0
  14. package/hooks/session-checkpoint.sh +7 -0
  15. package/hooks/session-start.sh +7 -0
  16. package/install.py +401 -0
  17. package/install.sh +260 -0
  18. package/package.json +24 -0
  19. package/pytest.ini +2 -0
  20. package/registry.json +26 -0
  21. package/server/.env.example +24 -0
  22. package/server/__init__.py +0 -0
  23. package/server/config.py +89 -0
  24. package/server/main.py +93 -0
  25. package/server/personas/analyst.md +15 -0
  26. package/server/personas/ceo.md +14 -0
  27. package/server/personas/code_agent.md +15 -0
  28. package/server/personas/cto.md +14 -0
  29. package/server/provision.py +260 -0
  30. package/server/provision_opensearch.py +154 -0
  31. package/server/requirements.txt +26 -0
  32. package/server/storage/__init__.py +0 -0
  33. package/server/storage/dynamo.py +399 -0
  34. package/server/storage/json_store.py +359 -0
  35. package/server/storage/opensearch.py +194 -0
  36. package/server/storage/s3.py +96 -0
  37. package/server/storage/tests/__init__.py +0 -0
  38. package/server/storage/tests/test_dynamo.py +452 -0
  39. package/server/storage/tests/test_json_store.py +226 -0
  40. package/server/storage/tests/test_opensearch.py +270 -0
  41. package/server/storage/tests/test_s3.py +125 -0
  42. package/server/tests/__init__.py +0 -0
  43. package/server/tests/test_install.py +606 -0
  44. package/server/tests/test_isolation.py +90 -0
  45. package/server/tests/test_ui_server.py +385 -0
  46. package/server/tests/test_ui_shader_background.py +52 -0
  47. package/server/tests/test_ui_story_6_3.py +105 -0
  48. package/server/tools/__init__.py +0 -0
  49. package/server/tools/_mcp.py +4 -0
  50. package/server/tools/agents.py +160 -0
  51. package/server/tools/ecc/__init__.py +1 -0
  52. package/server/tools/ecc/data.py +35 -0
  53. package/server/tools/ecc/diagrams.py +23 -0
  54. package/server/tools/ecc/pipeline.py +24 -0
  55. package/server/tools/ecc/presentation.py +24 -0
  56. package/server/tools/ecc/web.py +23 -0
  57. package/server/tools/executor.py +880 -0
  58. package/server/tools/harness.py +330 -0
  59. package/server/tools/help.py +162 -0
  60. package/server/tools/hooks.py +357 -0
  61. package/server/tools/personas.py +110 -0
  62. package/server/tools/registry.py +195 -0
  63. package/server/tools/router.py +117 -0
  64. package/server/tools/skill_installer.py +230 -0
  65. package/server/tools/summarizer.py +168 -0
  66. package/server/tools/tests/__init__.py +0 -0
  67. package/server/tools/tests/test_agents.py +246 -0
  68. package/server/tools/tests/test_code_index.py +108 -0
  69. package/server/tools/tests/test_ecc_tools.py +51 -0
  70. package/server/tools/tests/test_executor.py +584 -0
  71. package/server/tools/tests/test_help_agent101.py +149 -0
  72. package/server/tools/tests/test_hooks.py +124 -0
  73. package/server/tools/tests/test_kill_thread.py +125 -0
  74. package/server/tools/tests/test_new_thread_list_threads.py +293 -0
  75. package/server/tools/tests/test_personas.py +52 -0
  76. package/server/tools/tests/test_recall_memory.py +55 -0
  77. package/server/tools/tests/test_registry_client.py +308 -0
  78. package/server/tools/tests/test_router.py +263 -0
  79. package/server/tools/tests/test_skill_installer.py +174 -0
  80. package/server/tools/tests/test_switch_thread.py +163 -0
  81. package/server/tools/tests/test_thread_command_skills.py +54 -0
  82. package/server/tools/tests/test_thread_resolver.py +165 -0
  83. package/server/tools/tests/test_tier1_schema.py +296 -0
  84. package/server/tools/thread_resolver.py +75 -0
  85. package/server/tools/tylor.py +374 -0
  86. package/server/tools/ui.py +38 -0
  87. package/server/ui_server.py +292 -0
  88. package/server/validate.py +237 -0
  89. package/skills/add-skill/SKILL.md +37 -0
  90. package/skills/afk-status/SKILL.md +20 -0
  91. package/skills/bmad/SKILL.md +14 -0
  92. package/skills/help-agent101/SKILL.md +48 -0
  93. package/skills/kill-thread/SKILL.md +35 -0
  94. package/skills/list-threads/SKILL.md +35 -0
  95. package/skills/new-thread/SKILL.md +35 -0
  96. package/skills/recall/SKILL.md +39 -0
  97. package/skills/run/SKILL.md +33 -0
  98. package/skills/set-sandbox/SKILL.md +38 -0
  99. package/skills/switch-thread/SKILL.md +38 -0
  100. package/ui/claude-logo.png +0 -0
  101. package/ui/index.html +1314 -0
@@ -0,0 +1,357 @@
1
+ """
2
+ server/tools/hooks.py — Claude Code lifecycle hook helpers.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import asyncio
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+ import re
12
+ import subprocess
13
+ import sys
14
+ from typing import Any, Callable
15
+
16
+ from server.tools.summarizer import summarize_thread
17
+ from server.tools.tylor import _get_db, _get_memory_client, _now_iso, list_threads
18
+
19
+
20
+ CODE_INDEX_QUERY = "code index"
21
+ CODE_INDEX_MAX_ENTRIES = 30
22
+ CODE_INDEX_TOKEN_BUDGET = 150
23
+ INDEXABLE_SUFFIXES = {".py", ".js", ".jsx", ".ts", ".tsx"}
24
+ SKIP_SUFFIXES = {".json", ".yaml", ".yml", ".toml", ".md", ".txt", ".env"}
25
+ SKIP_NAME_PARTS = {"config", "settings", "requirements", "package-lock"}
26
+
27
+
28
+ def _rough_word_count(text: str) -> int:
29
+ # Approximation only — actual LLM token count is ~1.3–1.5× word count.
30
+ # Used for budget enforcement; keeps header well under the true token limit.
31
+ return len(text.split())
32
+
33
+
34
+ def _code_index_header(thread_name: str, facts: list[dict], max_tokens: int = CODE_INDEX_TOKEN_BUDGET) -> str:
35
+ header = f"[{thread_name or 'Active'} Thread — Code Index]"
36
+ lines = [header]
37
+ used = _rough_word_count(header)
38
+ sorted_facts = sorted(
39
+ facts,
40
+ key=lambda fact: fact.get("last_used_at") or fact.get("created_at") or "",
41
+ reverse=True,
42
+ )
43
+ for fact in sorted_facts:
44
+ content = str(fact.get("content", "")).strip()
45
+ if not content:
46
+ continue
47
+ cost = _rough_word_count(content)
48
+ if used + cost > max_tokens:
49
+ continue # skip this entry but keep trying smaller ones
50
+ lines.append(content)
51
+ used += cost
52
+ return "\n".join(lines) if len(lines) > 1 else ""
53
+
54
+
55
+ def build_code_index_header(
56
+ thread_id: str,
57
+ thread_name: str,
58
+ memory_client: Any | None = None,
59
+ max_tokens: int = CODE_INDEX_TOKEN_BUDGET,
60
+ ) -> str:
61
+ """Return compact code-index header for active thread context injection."""
62
+ try:
63
+ memory = memory_client or _get_memory_client()
64
+ facts = memory.search_memory(
65
+ thread_id=thread_id,
66
+ query=CODE_INDEX_QUERY,
67
+ k=CODE_INDEX_MAX_ENTRIES,
68
+ type="code_index",
69
+ )
70
+ except Exception:
71
+ return ""
72
+ return _code_index_header(thread_name, facts, max_tokens=max_tokens)
73
+
74
+
75
+ def _thread_line(thread: dict) -> str:
76
+ return (
77
+ f"- {thread.get('name', '(unnamed)')} "
78
+ f"({thread.get('thread_id')}, {thread.get('status', 'unknown')}, "
79
+ f"{thread.get('message_count', 0)} messages)"
80
+ )
81
+
82
+
83
+ def session_start_message(
84
+ db: Any | None = None,
85
+ list_threads_fn: Callable[[], dict] = list_threads,
86
+ memory_client: Any | None = None,
87
+ ) -> str:
88
+ """Return active-thread context for Claude Code SessionStart output."""
89
+ db = db or _get_db()
90
+ marker = db.get_current_thread_marker()
91
+ threads = list_threads_fn().get("threads", [])
92
+
93
+ if not marker or not marker.get("CurrentThreadId"):
94
+ if not threads:
95
+ return "agent101: No active thread found. Start with /new-thread when ready."
96
+ thread_list = "\n".join(_thread_line(thread) for thread in threads[:5])
97
+ return (
98
+ "agent101 thread context: No active thread marker found.\n"
99
+ "Recent threads:\n"
100
+ f"{thread_list}"
101
+ )
102
+
103
+ current_id = marker["CurrentThreadId"]
104
+ current = next((thread for thread in threads if thread.get("thread_id") == current_id), None)
105
+ if not current:
106
+ meta = db.get_thread_meta(current_id) or {}
107
+ current = {
108
+ "thread_id": current_id,
109
+ "name": meta.get("Name", ""),
110
+ "status": meta.get("Status", "unknown"),
111
+ "message_count": meta.get("MessageCount", 0),
112
+ }
113
+
114
+ thread_list = "\n".join(_thread_line(thread) for thread in threads[:5])
115
+ code_header = build_code_index_header(
116
+ current_id,
117
+ current.get("name", ""),
118
+ memory_client=memory_client,
119
+ )
120
+ code_block = f"{code_header}\n\n" if code_header else ""
121
+ return (
122
+ f"{code_block}"
123
+ "agent101 active thread context:\n"
124
+ f"Active thread: {_thread_line(current)}\n"
125
+ f"Active since: {marker.get('ActiveAt', 'unknown')}\n"
126
+ "Recent threads:\n"
127
+ f"{thread_list}\n"
128
+ "Acknowledge this active thread before continuing."
129
+ )
130
+
131
+
132
+ def checkpoint_current_thread(
133
+ db: Any | None = None,
134
+ now_fn: Callable[[], str] = _now_iso,
135
+ ) -> dict:
136
+ """Snapshot current thread metadata with a fresh checkpoint timestamp."""
137
+ db = db or _get_db()
138
+ marker = db.get_current_thread_marker()
139
+ if not marker or not marker.get("CurrentThreadId"):
140
+ return {"status": "skipped", "reason": "no_active_thread"}
141
+
142
+ thread_id = marker["CurrentThreadId"]
143
+ meta = db.get_thread_meta(thread_id)
144
+ if not meta:
145
+ return {"status": "skipped", "reason": "active_thread_missing", "thread_id": thread_id}
146
+
147
+ now = now_fn()
148
+ updated = dict(meta)
149
+ updated["LastActivity"] = now
150
+ updated["CheckpointAt"] = now
151
+ db.put_item(f"THREAD#{thread_id}#META", updated)
152
+ return {"status": "checkpointed", "thread_id": thread_id}
153
+
154
+
155
+ def dispatch_kill_thread_summary(
156
+ thread_id: str,
157
+ project_root: Path | str | None = None,
158
+ python_executable: str = sys.executable,
159
+ ) -> dict:
160
+ """Start summarization in a detached process and return immediately."""
161
+ root = Path(project_root or Path(__file__).resolve().parents[2])
162
+ subprocess.Popen(
163
+ [
164
+ python_executable,
165
+ "-m",
166
+ "server.tools.hooks",
167
+ "summarize-thread",
168
+ thread_id,
169
+ ],
170
+ cwd=str(root),
171
+ stdout=subprocess.DEVNULL,
172
+ stderr=subprocess.DEVNULL,
173
+ stdin=subprocess.DEVNULL,
174
+ start_new_session=True,
175
+ )
176
+ return {"status": "dispatched", "thread_id": thread_id}
177
+
178
+
179
+ def _thread_id_from_hook_payload(payload: str) -> str | None:
180
+ if not payload.strip():
181
+ return None
182
+ try:
183
+ data = json.loads(payload)
184
+ except json.JSONDecodeError:
185
+ return None
186
+ for path in (
187
+ ("tool_input", "thread_id"),
188
+ ("input", "thread_id"),
189
+ ("thread_id",),
190
+ ):
191
+ value: Any = data
192
+ for key in path:
193
+ if not isinstance(value, dict):
194
+ value = None
195
+ break
196
+ value = value.get(key)
197
+ if isinstance(value, str) and value:
198
+ return value
199
+ return None
200
+
201
+
202
+ def _file_path_from_hook_payload(payload: str) -> str | None:
203
+ if not payload.strip():
204
+ return None
205
+ try:
206
+ data = json.loads(payload)
207
+ except json.JSONDecodeError:
208
+ return None
209
+ for path in (
210
+ ("tool_input", "file_path"),
211
+ ("tool_input", "path"),
212
+ ("input", "file_path"),
213
+ ("input", "path"),
214
+ ("file_path",),
215
+ ("path",),
216
+ ):
217
+ value: Any = data
218
+ for key in path:
219
+ if not isinstance(value, dict):
220
+ value = None
221
+ break
222
+ value = value.get(key)
223
+ if isinstance(value, str) and value:
224
+ return value
225
+ return None
226
+
227
+
228
+ _MAX_FILE_BYTES = 512 * 1024 # 512 KB — skip larger files to avoid blocking
229
+
230
+
231
+ def _extract_code_index_fact(file_path: Path, project_root: Path | None = None) -> str | None:
232
+ if file_path.suffix not in INDEXABLE_SUFFIXES:
233
+ return None
234
+ lowered = file_path.name.lower()
235
+ if any(part in lowered for part in SKIP_NAME_PARTS):
236
+ return None
237
+ try:
238
+ if file_path.stat().st_size > _MAX_FILE_BYTES:
239
+ return None
240
+ lines = file_path.read_text(encoding="utf-8", errors="replace").splitlines()
241
+ except OSError:
242
+ return None
243
+
244
+ # Hooks checked before generic components — use[A-Z] names would match component patterns first
245
+ patterns = [
246
+ (re.compile(r"^\s*export\s+function\s+(use[A-Z][A-Za-z0-9_]*)\b"), "hook"),
247
+ (re.compile(r"^\s*function\s+(use[A-Z][A-Za-z0-9_]*)\b"), "hook"),
248
+ (re.compile(r"^\s*export\s+function\s+([A-Z][A-Za-z0-9_]*)\b"), "component"),
249
+ (re.compile(r"^\s*function\s+([A-Z][A-Za-z0-9_]*)\b"), "component"),
250
+ (re.compile(r"^\s*export\s+const\s+([A-Z][A-Za-z0-9_]*)\b"), "component"),
251
+ (re.compile(r"^\s*const\s+([A-Z][A-Za-z0-9_]*)\b"), "component"),
252
+ (re.compile(r"^\s*def\s+([A-Za-z_][A-Za-z0-9_]*)\("), "python function"),
253
+ (re.compile(r"^\s*class\s+([A-Za-z_][A-Za-z0-9_]*)\b"), "class"),
254
+ ]
255
+ # Use relative path for portability and token efficiency
256
+ try:
257
+ root = project_root or Path.cwd()
258
+ display_path = file_path.relative_to(root)
259
+ except ValueError:
260
+ display_path = file_path # fallback to absolute if not under project root
261
+
262
+ for line_no, line in enumerate(lines, start=1):
263
+ for pattern, label in patterns:
264
+ match = pattern.search(line)
265
+ if match:
266
+ symbol = match.group(1)
267
+ return f"{symbol}: {display_path}:{line_no} — {label}"
268
+ return None
269
+
270
+
271
+ def index_code_file_for_active_thread(
272
+ file_path: str,
273
+ db: Any | None = None,
274
+ memory_client: Any | None = None,
275
+ project_root: Path | str | None = None,
276
+ ) -> dict:
277
+ """Index one touched source file as a compact code_index memory fact."""
278
+ path = Path(file_path).expanduser()
279
+ root = Path(project_root).expanduser() if project_root else Path.cwd()
280
+ fact = _extract_code_index_fact(path, project_root=root)
281
+ if not fact:
282
+ return {"status": "skipped", "reason": "no_indexable_symbol"}
283
+
284
+ db = db or _get_db()
285
+ marker = db.get_current_thread_marker()
286
+ thread_id = marker.get("CurrentThreadId") if marker else None
287
+ if not thread_id:
288
+ return {"status": "skipped", "reason": "no_active_thread"}
289
+
290
+ memory = memory_client or _get_memory_client()
291
+ memory_id = memory.index_memory(
292
+ thread_id=thread_id,
293
+ fact=fact,
294
+ metadata={"type": "code_index"},
295
+ )
296
+ return {"status": "indexed", "thread_id": thread_id, "memory_id": memory_id, "fact": fact}
297
+
298
+
299
+ async def _summarize_thread_cli(thread_id: str) -> None:
300
+ await summarize_thread(thread_id, db=_get_db())
301
+
302
+
303
+ def main(argv: list[str] | None = None) -> int:
304
+ parser = argparse.ArgumentParser(description="agent101 Claude Code hook helper")
305
+ sub = parser.add_subparsers(dest="command", required=True)
306
+ sub.add_parser("session-start")
307
+ sub.add_parser("session-checkpoint")
308
+ sub.add_parser("post-tool-use-code-index")
309
+ kill_parser = sub.add_parser("kill-thread-trigger")
310
+ kill_parser.add_argument("thread_id", nargs="?")
311
+ summarize_parser = sub.add_parser("summarize-thread")
312
+ summarize_parser.add_argument("thread_id")
313
+
314
+ args = parser.parse_args(argv)
315
+
316
+ if args.command == "session-start":
317
+ # Read cwd from Claude Code's stdin JSON payload and persist it
318
+ try:
319
+ raw = sys.stdin.read()
320
+ if raw.strip():
321
+ payload = json.loads(raw)
322
+ cwd = payload.get("cwd") or payload.get("session", {}).get("cwd")
323
+ if cwd:
324
+ from pathlib import Path as _Path
325
+ proj_file = _Path.home() / ".tylor" / "current_project.txt"
326
+ proj_file.parent.mkdir(parents=True, exist_ok=True)
327
+ proj_file.write_text(cwd)
328
+ except Exception:
329
+ pass
330
+ print(session_start_message())
331
+ return 0
332
+ if args.command == "session-checkpoint":
333
+ print(json.dumps(checkpoint_current_thread()))
334
+ return 0
335
+ if args.command == "post-tool-use-code-index":
336
+ file_path = _file_path_from_hook_payload(sys.stdin.read())
337
+ if not file_path:
338
+ print(json.dumps({"status": "skipped", "reason": "missing_file_path"}))
339
+ return 0
340
+ print(json.dumps(index_code_file_for_active_thread(file_path)))
341
+ return 0
342
+ if args.command == "kill-thread-trigger":
343
+ thread_id = args.thread_id or _thread_id_from_hook_payload(sys.stdin.read())
344
+ if not thread_id:
345
+ print(json.dumps({"status": "skipped", "reason": "missing_thread_id"}))
346
+ return 0
347
+ print(json.dumps(dispatch_kill_thread_summary(thread_id)))
348
+ return 0
349
+ if args.command == "summarize-thread":
350
+ asyncio.run(_summarize_thread_cli(args.thread_id))
351
+ return 0
352
+
353
+ return 2
354
+
355
+
356
+ if __name__ == "__main__":
357
+ raise SystemExit(main())
@@ -0,0 +1,110 @@
1
+ """
2
+ server/tools/personas.py — persona definition loading for agent orchestration.
3
+
4
+ Story 3.1 stores personas as structured markdown. This module provides the
5
+ deterministic parser used by Story 3.2 MCP tools.
6
+ """
7
+ from __future__ import annotations
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+
12
+ PERSONAS_DIR = Path(__file__).resolve().parents[1] / "personas"
13
+ REQUIRED_PERSONAS = ("analyst", "ceo", "code_agent", "cto")
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class PersonaDefinition:
18
+ name: str
19
+ display_name: str
20
+ role_summary: str
21
+ communication_style: str
22
+ ecc_tool_categories: list[str]
23
+ role_prompt: str
24
+
25
+ def summary(self) -> dict:
26
+ return {
27
+ "name": self.name,
28
+ "role_summary": self.role_summary,
29
+ "ecc_tool_categories": list(self.ecc_tool_categories),
30
+ }
31
+
32
+
33
+ def _section(lines: list[str], heading: str) -> list[str]:
34
+ start = None
35
+ marker = f"## {heading}"
36
+ for idx, line in enumerate(lines):
37
+ if line.strip() == marker:
38
+ start = idx + 1
39
+ break
40
+ if start is None:
41
+ return []
42
+
43
+ end = len(lines)
44
+ for idx in range(start, len(lines)):
45
+ if lines[idx].startswith("## "):
46
+ end = idx
47
+ break
48
+ return [line.rstrip() for line in lines[start:end] if line.strip()]
49
+
50
+
51
+ def _parse_ecc_categories(lines: list[str]) -> list[str]:
52
+ categories: list[str] = []
53
+ for line in lines:
54
+ value = line.strip().removeprefix("-").strip().strip("`")
55
+ if value.startswith("ecc/"):
56
+ categories.append(value)
57
+ return categories
58
+
59
+
60
+ def load_persona(name: str) -> PersonaDefinition | None:
61
+ normalized = name.strip().lower().replace("-", "_")
62
+ if normalized not in REQUIRED_PERSONAS:
63
+ return None
64
+
65
+ path = PERSONAS_DIR / f"{normalized}.md"
66
+ if not path.exists():
67
+ return None
68
+
69
+ text = path.read_text(encoding="utf-8")
70
+ lines = text.splitlines()
71
+ display_name = next(
72
+ (line.removeprefix("#").strip() for line in lines if line.startswith("# ")),
73
+ normalized,
74
+ )
75
+ role_lines = _section(lines, "Role Description")
76
+ style_lines = _section(lines, "Communication Style")
77
+ category_lines = _section(lines, "ECC Tool Categories")
78
+
79
+ role_summary = " ".join(role_lines).strip()
80
+ communication_style = " ".join(style_lines).strip()
81
+ ecc_tool_categories = _parse_ecc_categories(category_lines)
82
+ role_prompt = (
83
+ f"# {display_name}\n\n"
84
+ f"## Role Description\n{role_summary}\n\n"
85
+ f"## Communication Style\n{communication_style}\n\n"
86
+ "## ECC Tool Categories\n"
87
+ + "\n".join(f"- `{category}`" for category in ecc_tool_categories)
88
+ )
89
+
90
+ return PersonaDefinition(
91
+ name=normalized,
92
+ display_name=display_name,
93
+ role_summary=role_summary,
94
+ communication_style=communication_style,
95
+ ecc_tool_categories=ecc_tool_categories,
96
+ role_prompt=role_prompt,
97
+ )
98
+
99
+
100
+ def load_personas() -> list[PersonaDefinition]:
101
+ personas = []
102
+ for name in REQUIRED_PERSONAS:
103
+ persona = load_persona(name)
104
+ if persona is not None:
105
+ personas.append(persona)
106
+ return personas
107
+
108
+
109
+ def list_persona_summaries() -> list[dict]:
110
+ return [persona.summary() for persona in load_personas()]
@@ -0,0 +1,195 @@
1
+ """
2
+ server/tools/registry.py — Tier 1 skill registry MCP tools.
3
+ FR36-FR39: load_skill_tools, list_registry.
4
+
5
+ Story 4.1 implements built-in ECC category loading. Story 4.2 expands this
6
+ to the full registry client for external skill groups.
7
+ """
8
+ from __future__ import annotations
9
+ import importlib
10
+ import json
11
+ import re
12
+ from pathlib import Path
13
+
14
+ from mcp.shared.exceptions import McpError
15
+ from mcp.types import ErrorData, INVALID_PARAMS
16
+
17
+ from ._mcp import mcp
18
+
19
+ PLUGIN_DIR = Path(__file__).resolve().parents[2]
20
+ REGISTRY_PATH = PLUGIN_DIR / "registry.json"
21
+
22
+ ECC_GROUPS = {
23
+ "ecc/web": ("server.tools.ecc.web", ["web_scrape", "web_fetch"]),
24
+ "ecc/data": ("server.tools.ecc.data", ["dataset_manager", "data_clean", "data_transform"]),
25
+ "ecc/presentation": ("server.tools.ecc.presentation", ["build_pptx", "build_doc"]),
26
+ "ecc/diagrams": ("server.tools.ecc.diagrams", ["diagram_gen", "flowchart_gen"]),
27
+ "ecc/pipeline": ("server.tools.ecc.pipeline", ["pipeline_builder", "pipeline_run"]),
28
+ }
29
+
30
+
31
+ def _invalid_category(tool_group: str) -> McpError:
32
+ return McpError(
33
+ ErrorData(
34
+ code=INVALID_PARAMS,
35
+ message=f"Unknown skill category: {tool_group}",
36
+ )
37
+ )
38
+
39
+
40
+ def _read_registry() -> dict:
41
+ if not REGISTRY_PATH.exists():
42
+ return {"version": "1.0", "skills": []}
43
+ return json.loads(REGISTRY_PATH.read_text(encoding="utf-8"))
44
+
45
+
46
+ def _registry_skills() -> list[dict]:
47
+ data = _read_registry()
48
+ return data.get("skills", [])
49
+
50
+
51
+ def _find_registry_skill(name: str) -> dict | None:
52
+ normalized = name.strip().lower()
53
+ for skill in _registry_skills():
54
+ if skill.get("name", "").strip().lower() == normalized:
55
+ return skill
56
+ return None
57
+
58
+
59
+ def _task_tokens(task: str) -> set[str]:
60
+ return set(re.findall(r"[a-z0-9]+", task.lower()))
61
+
62
+
63
+ def _skill_keyword_tokens(skill: dict) -> set[str]:
64
+ tokens: set[str] = set()
65
+ for keyword in skill.get("keywords") or []:
66
+ if isinstance(keyword, str):
67
+ tokens.update(_task_tokens(keyword))
68
+ return tokens
69
+
70
+
71
+ def _native_slash_skill(task: str) -> str | None:
72
+ stripped = task.strip()
73
+ if not stripped.startswith("/"):
74
+ return None
75
+ command = stripped.split(maxsplit=1)[0][1:].lower()
76
+ return command or None
77
+
78
+
79
+ def _suggestion(skill_name: str) -> dict:
80
+ return {
81
+ "matched": True,
82
+ "skill": skill_name,
83
+ "action": "suggest",
84
+ "message": f"You have {skill_name.upper()} in agent101 — want me to use it?",
85
+ "thread_persistence": True,
86
+ }
87
+
88
+
89
+ import re as _re
90
+ _SAFE_MODULE_RE = _re.compile(r'^server\.tools\.[a-z0-9_.]+$')
91
+
92
+
93
+ def _load_module_group(tool_group: str, module_path: str, tools: list[str]) -> dict:
94
+ if not _SAFE_MODULE_RE.match(module_path):
95
+ from mcp.server.fastmcp.exceptions import ToolError
96
+ raise ToolError(
97
+ f"Unsafe module path '{module_path}' — must match server.tools.<name>"
98
+ )
99
+ importlib.import_module(module_path)
100
+ return {
101
+ "tool_group": tool_group,
102
+ "status": "loaded",
103
+ "tools": sorted(tools),
104
+ }
105
+
106
+
107
+ @mcp.tool()
108
+ def load_skill_tools(tool_group: str) -> dict:
109
+ """
110
+ Lazy-load a Tier 2 tool group into the active session manifest.
111
+ Use when a task matches a specific skill category.
112
+ Available groups: ecc/web, ecc/data, ecc/presentation, ecc/diagrams, ecc/pipeline.
113
+
114
+ Args:
115
+ tool_group: The tool group path to load (e.g. "ecc/web", "ecc/data").
116
+ """
117
+ if tool_group in ECC_GROUPS:
118
+ module_path, tools = ECC_GROUPS[tool_group]
119
+ return _load_module_group(tool_group, module_path, tools)
120
+
121
+ skill = _find_registry_skill(tool_group)
122
+ if not skill:
123
+ raise _invalid_category(tool_group)
124
+
125
+ module_path = skill.get("module")
126
+ tools = skill.get("tools", [])
127
+ if not module_path or not tools:
128
+ raise _invalid_category(tool_group)
129
+
130
+ return _load_module_group(tool_group, module_path, tools)
131
+
132
+
133
+ def detect_registry_skill(task: str, auto_load: bool = False) -> dict:
134
+ """
135
+ Detect whether a user task matches an installed agent101 registry skill.
136
+ Non-matches never import Tier 2 modules, keeping the startup manifest lean.
137
+
138
+ Args:
139
+ task: User task text to evaluate against registry trigger metadata.
140
+ auto_load: When true, load the matched registry skill's Tier 2 tools.
141
+ """
142
+ native_command = _native_slash_skill(task)
143
+ if native_command and _find_registry_skill(native_command):
144
+ return {
145
+ "matched": True,
146
+ "skill": native_command,
147
+ "action": "claude_native",
148
+ "thread_persistence": False,
149
+ }
150
+
151
+ tokens = _task_tokens(task)
152
+ for skill in _registry_skills():
153
+ name = skill.get("name", "").strip()
154
+ if not name or not tokens.intersection(_skill_keyword_tokens(skill)):
155
+ continue
156
+
157
+ if auto_load and skill.get("module") and skill.get("tools"):
158
+ return {
159
+ "matched": True,
160
+ "skill": name,
161
+ "action": "loaded",
162
+ "loaded": load_skill_tools(name),
163
+ "thread_persistence": True,
164
+ }
165
+
166
+ return _suggestion(name)
167
+
168
+ return {"matched": False, "action": "none"}
169
+
170
+
171
+ @mcp.tool()
172
+ def list_registry() -> dict:
173
+ """
174
+ List all installed skills from registry.json with their trigger descriptions.
175
+ Returns skills sorted by install date descending.
176
+ """
177
+ skills = sorted(
178
+ _registry_skills(),
179
+ key=lambda skill: skill.get("installed_date", ""),
180
+ reverse=True,
181
+ )
182
+ return {
183
+ "skills": [
184
+ {
185
+ "name": skill.get("name", ""),
186
+ "trigger_description": skill.get(
187
+ "trigger_description",
188
+ skill.get("trigger", ""),
189
+ ),
190
+ "keywords": list(skill.get("keywords", [])),
191
+ "tool_count": int(skill.get("tool_count", 0)),
192
+ }
193
+ for skill in skills
194
+ ]
195
+ }