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.
- package/.aws-setup.sh +25 -0
- package/.claude-plugin/plugin.json +22 -0
- package/.mcp.json +12 -0
- package/AGENTS.md +93 -0
- package/CLAUDE.md +99 -0
- package/CLAUDE_PLATFORM_AWS_SETUP.md +105 -0
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/assets/tylor_logo.png +0 -0
- package/assets/tylor_threads_concept.png +0 -0
- package/bin/tylor.js +23 -0
- package/hooks/kill-thread-trigger.sh +7 -0
- package/hooks/post-tool-use-code-index.sh +7 -0
- package/hooks/session-checkpoint.sh +7 -0
- package/hooks/session-start.sh +7 -0
- package/install.py +401 -0
- package/install.sh +260 -0
- package/package.json +24 -0
- package/pytest.ini +2 -0
- package/registry.json +26 -0
- package/server/.env.example +24 -0
- package/server/__init__.py +0 -0
- package/server/config.py +89 -0
- package/server/main.py +93 -0
- package/server/personas/analyst.md +15 -0
- package/server/personas/ceo.md +14 -0
- package/server/personas/code_agent.md +15 -0
- package/server/personas/cto.md +14 -0
- package/server/provision.py +260 -0
- package/server/provision_opensearch.py +154 -0
- package/server/requirements.txt +26 -0
- package/server/storage/__init__.py +0 -0
- package/server/storage/dynamo.py +399 -0
- package/server/storage/json_store.py +359 -0
- package/server/storage/opensearch.py +194 -0
- package/server/storage/s3.py +96 -0
- package/server/storage/tests/__init__.py +0 -0
- package/server/storage/tests/test_dynamo.py +452 -0
- package/server/storage/tests/test_json_store.py +226 -0
- package/server/storage/tests/test_opensearch.py +270 -0
- package/server/storage/tests/test_s3.py +125 -0
- package/server/tests/__init__.py +0 -0
- package/server/tests/test_install.py +606 -0
- package/server/tests/test_isolation.py +90 -0
- package/server/tests/test_ui_server.py +385 -0
- package/server/tests/test_ui_shader_background.py +52 -0
- package/server/tests/test_ui_story_6_3.py +105 -0
- package/server/tools/__init__.py +0 -0
- package/server/tools/_mcp.py +4 -0
- package/server/tools/agents.py +160 -0
- package/server/tools/ecc/__init__.py +1 -0
- package/server/tools/ecc/data.py +35 -0
- package/server/tools/ecc/diagrams.py +23 -0
- package/server/tools/ecc/pipeline.py +24 -0
- package/server/tools/ecc/presentation.py +24 -0
- package/server/tools/ecc/web.py +23 -0
- package/server/tools/executor.py +880 -0
- package/server/tools/harness.py +330 -0
- package/server/tools/help.py +162 -0
- package/server/tools/hooks.py +357 -0
- package/server/tools/personas.py +110 -0
- package/server/tools/registry.py +195 -0
- package/server/tools/router.py +117 -0
- package/server/tools/skill_installer.py +230 -0
- package/server/tools/summarizer.py +168 -0
- package/server/tools/tests/__init__.py +0 -0
- package/server/tools/tests/test_agents.py +246 -0
- package/server/tools/tests/test_code_index.py +108 -0
- package/server/tools/tests/test_ecc_tools.py +51 -0
- package/server/tools/tests/test_executor.py +584 -0
- package/server/tools/tests/test_help_agent101.py +149 -0
- package/server/tools/tests/test_hooks.py +124 -0
- package/server/tools/tests/test_kill_thread.py +125 -0
- package/server/tools/tests/test_new_thread_list_threads.py +293 -0
- package/server/tools/tests/test_personas.py +52 -0
- package/server/tools/tests/test_recall_memory.py +55 -0
- package/server/tools/tests/test_registry_client.py +308 -0
- package/server/tools/tests/test_router.py +263 -0
- package/server/tools/tests/test_skill_installer.py +174 -0
- package/server/tools/tests/test_switch_thread.py +163 -0
- package/server/tools/tests/test_thread_command_skills.py +54 -0
- package/server/tools/tests/test_thread_resolver.py +165 -0
- package/server/tools/tests/test_tier1_schema.py +296 -0
- package/server/tools/thread_resolver.py +75 -0
- package/server/tools/tylor.py +374 -0
- package/server/tools/ui.py +38 -0
- package/server/ui_server.py +292 -0
- package/server/validate.py +237 -0
- package/skills/add-skill/SKILL.md +37 -0
- package/skills/afk-status/SKILL.md +20 -0
- package/skills/bmad/SKILL.md +14 -0
- package/skills/help-agent101/SKILL.md +48 -0
- package/skills/kill-thread/SKILL.md +35 -0
- package/skills/list-threads/SKILL.md +35 -0
- package/skills/new-thread/SKILL.md +35 -0
- package/skills/recall/SKILL.md +39 -0
- package/skills/run/SKILL.md +33 -0
- package/skills/set-sandbox/SKILL.md +38 -0
- package/skills/switch-thread/SKILL.md +38 -0
- package/ui/claude-logo.png +0 -0
- 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
|
+
}
|