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,374 @@
1
+ """
2
+ server/tools/tylor.py — Tier 1 thread lifecycle MCP tools.
3
+ FR1-FR8: new_thread, switch_thread, kill_thread, recall_memory, list_threads.
4
+
5
+ Stories 2.3–2.5 implement storage. switch_thread (2.4), kill_thread (2.7),
6
+ recall_memory (2.5) remain stubs until their stories are implemented.
7
+ """
8
+ from __future__ import annotations
9
+ import os
10
+ import re
11
+ import asyncio
12
+ import uuid
13
+ from datetime import datetime, timezone
14
+
15
+ from mcp.server.fastmcp.exceptions import ToolError
16
+
17
+ from ._mcp import mcp
18
+ from .summarizer import summarize_thread
19
+ from .thread_resolver import resolve_thread_name
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Broadcast helper
24
+ # ---------------------------------------------------------------------------
25
+
26
+ _broadcast_tasks: set = set() # holds task refs to prevent GC before completion
27
+
28
+
29
+ def _broadcast_thread_update() -> None:
30
+ """Fire-and-forget WebSocket broadcast after any thread state change."""
31
+ try:
32
+ from ..ui_server import ws_manager, thread_update_payload, ui_available
33
+ if not ui_available or ws_manager.count == 0:
34
+ return
35
+ loop = asyncio.get_running_loop()
36
+ task = loop.create_task(_do_broadcast())
37
+ _broadcast_tasks.add(task)
38
+ task.add_done_callback(_broadcast_tasks.discard)
39
+ except RuntimeError:
40
+ pass # no running loop — broadcast not possible from this context
41
+ except Exception:
42
+ pass # broadcast is best-effort; never break MCP tool execution
43
+
44
+
45
+ async def _do_broadcast() -> None:
46
+ from ..ui_server import ws_manager, thread_update_payload
47
+ payload = await thread_update_payload()
48
+ await ws_manager.broadcast(payload)
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Constants
52
+ # ---------------------------------------------------------------------------
53
+
54
+ NAME_MIN = 3
55
+ NAME_MAX = 64
56
+ _NAME_RE = re.compile(r"^[a-zA-Z0-9 _-]+$")
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Helpers
61
+ # ---------------------------------------------------------------------------
62
+
63
+ def _now_iso() -> str:
64
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
65
+
66
+
67
+ def _validate_name(name: str) -> None:
68
+ """Raise ToolError if name fails length / character / whitespace rules."""
69
+ if not (NAME_MIN <= len(name) <= NAME_MAX):
70
+ raise ToolError("Thread name must be 3–64 characters")
71
+ if not name.strip():
72
+ raise ToolError("Thread name contains invalid characters")
73
+ if not _NAME_RE.match(name):
74
+ raise ToolError("Thread name contains invalid characters")
75
+
76
+
77
+ def _get_db():
78
+ """
79
+ Return the configured storage backend.
80
+
81
+ Default: JsonStore (local ~/.tylor/threads.json, zero setup).
82
+ Optional: DynamoClient when AWS credentials + DYNAMO_TABLE are configured.
83
+ """
84
+ from server.config import config
85
+
86
+ # Use DynamoDB ONLY when user has explicitly set DYNAMO_TABLE env var
87
+ # (not just because AWS credentials happen to exist on the machine)
88
+ import os
89
+ use_dynamo = bool(os.environ.get("DYNAMO_TABLE") or os.environ.get("TYLOR_USE_DYNAMO"))
90
+
91
+ if use_dynamo:
92
+ from server.storage.dynamo import DynamoClient
93
+ return DynamoClient(
94
+ table_name=os.environ.get("DYNAMO_TABLE", "agent101"),
95
+ user_id=config.get("user_id", "default"),
96
+ profile=config.get("aws_profile"),
97
+ )
98
+
99
+ # Default: local JSON storage — no AWS required
100
+ from pathlib import Path
101
+ from server.storage.json_store import JsonStore
102
+ store_path = Path(config.get("storage_path") or Path.home() / ".tylor" / "threads.json")
103
+ return JsonStore(path=store_path)
104
+
105
+
106
+ def _get_memory_client():
107
+ """Return an OpenSearchClient configured from server.config."""
108
+ from server.config import config
109
+ from server.storage.opensearch import OpenSearchClient
110
+
111
+ host = config.get("opensearch_host")
112
+ if not host:
113
+ raise ToolError("OPENSEARCH_HOST not configured")
114
+
115
+ port = int(config.get("opensearch_port", "9200"))
116
+ return OpenSearchClient(
117
+ host=host,
118
+ port=port,
119
+ bedrock_region=config.get("bedrock_region", "us-east-1"),
120
+ profile=config.get("aws_profile"),
121
+ )
122
+
123
+
124
+ def _code_index_header_for_thread(thread_id: str, thread_name: str = "") -> str:
125
+ try:
126
+ from server.tools.hooks import build_code_index_header
127
+
128
+ return build_code_index_header(thread_id, thread_name)
129
+ except Exception:
130
+ return ""
131
+
132
+
133
+ # ---------------------------------------------------------------------------
134
+ # Tools
135
+ # ---------------------------------------------------------------------------
136
+
137
+ @mcp.tool()
138
+ def new_thread(name: str) -> dict:
139
+ """
140
+ Create a new named thread.
141
+ Returns the new thread's ID and metadata.
142
+
143
+ Args:
144
+ name: Human-readable name for the thread (e.g. "project-alpha", "weekly-review").
145
+ Must be 3–64 characters; alphanumeric, spaces, hyphens, underscores only.
146
+ """
147
+ _validate_name(name)
148
+
149
+ db = _get_db()
150
+
151
+ # Uniqueness check — query all META items before writing
152
+ existing = db.query_all("THREAD#")
153
+ for item in existing:
154
+ if item.get("SK", "").endswith("#META") and item.get("Name") == name:
155
+ raise ToolError("Thread name already exists")
156
+
157
+ thread_id = uuid.uuid4().hex
158
+ sk = f"THREAD#{thread_id}#META"
159
+ now = _now_iso()
160
+
161
+ # Capture current project folder name from Claude Code's PWD env var
162
+ try:
163
+ from pathlib import Path as _Path
164
+ pwd = os.environ.get("PWD") or os.environ.get("CLAUDE_PROJECT_DIR", "")
165
+ project_name = _Path(pwd).name if pwd else ""
166
+ except Exception:
167
+ project_name = ""
168
+
169
+ written = db.put_item(sk, {
170
+ "Name": name,
171
+ "Status": "active",
172
+ "LastActivity": now,
173
+ "MessageCount": 0,
174
+ "Project": project_name,
175
+ })
176
+
177
+ result = {
178
+ "thread_id": thread_id,
179
+ "name": name,
180
+ "created_at": written["CreatedAt"],
181
+ }
182
+ _broadcast_thread_update()
183
+
184
+ # Auto-open Thread Visualizer on first thread creation if not already open
185
+ try:
186
+ from .ui import _open_ui_browser
187
+ _open_ui_browser()
188
+ except Exception:
189
+ pass
190
+
191
+ return result
192
+
193
+
194
+ @mcp.tool()
195
+ def switch_thread(thread_id: str) -> dict:
196
+ """
197
+ Atomically switch to an existing thread, making it the active context.
198
+ Uses DynamoDB TransactWriteItems — partial writes fail explicitly.
199
+
200
+ Args:
201
+ thread_id: The unique ID of the thread to switch to.
202
+
203
+ Returns a dict. If the field ``code_index_header`` is present, prepend its
204
+ value verbatim to your context before continuing — it is the compact code
205
+ index for the thread you just switched into.
206
+ """
207
+ db = _get_db()
208
+ try:
209
+ result = db.switch_thread(thread_id)
210
+ except ToolError:
211
+ raise
212
+ except Exception as exc:
213
+ raise ToolError(
214
+ f"SwThread failed — both threads unchanged: {exc}"
215
+ ) from exc
216
+ meta = db.get_thread_meta(thread_id) or {}
217
+ header = _code_index_header_for_thread(thread_id, meta.get("Name", result.get("name", "")))
218
+ if header:
219
+ result = {**result, "code_index_header": header}
220
+ _broadcast_thread_update()
221
+ return result
222
+
223
+
224
+ @mcp.tool()
225
+ def switch_thread_by_name(query: str) -> dict:
226
+ """
227
+ Resolve a fuzzy thread-name query and switch to the matched thread.
228
+ Ambiguous matches are returned for Claude to present to the user.
229
+
230
+ Args:
231
+ query: Partial or approximate thread name.
232
+ """
233
+ db = _get_db()
234
+ all_items = db.query_all("THREAD#")
235
+ meta_items = [i for i in all_items if i.get("SK", "").endswith("#META")]
236
+ threads = []
237
+ for item in meta_items:
238
+ sk = item.get("SK", "")
239
+ threads.append({
240
+ "thread_id": sk.removeprefix("THREAD#").removesuffix("#META"),
241
+ "name": item.get("Name", ""),
242
+ "status": item.get("Status", "active"),
243
+ "last_activity": item.get("LastActivity", item.get("UpdatedAt", "")),
244
+ "message_count": int(item.get("MessageCount", 0)),
245
+ })
246
+
247
+ try:
248
+ resolved = resolve_thread_name(query, threads)
249
+ except ToolError:
250
+ raise
251
+ except Exception as exc:
252
+ # Let McpError propagate with its original error code intact
253
+ raise
254
+ if resolved["status"] == "ambiguous":
255
+ return resolved
256
+
257
+ try:
258
+ switched = db.switch_thread(resolved["thread_id"])
259
+ except ToolError:
260
+ raise
261
+ except Exception as exc:
262
+ raise ToolError(
263
+ f"SwThread failed — both threads unchanged: {exc}"
264
+ ) from exc
265
+ header = _code_index_header_for_thread(resolved["thread_id"], resolved.get("name", ""))
266
+ return {
267
+ **switched,
268
+ "name": resolved["name"],
269
+ "message": resolved["message"],
270
+ **({"code_index_header": header} if header else {}),
271
+ }
272
+
273
+
274
+ @mcp.tool()
275
+ def kill_thread(thread_id: str) -> dict:
276
+ """
277
+ Close a thread and trigger async Bedrock Opus summarization.
278
+ Returns immediately; summarization is dispatched via the PostToolUse hook.
279
+
280
+ Args:
281
+ thread_id: The unique ID of the thread to kill.
282
+ """
283
+ db = _get_db()
284
+ if not db.get_thread_meta(thread_id):
285
+ raise ToolError(f"Thread not found: {thread_id}")
286
+
287
+ # Mark as killed in storage so the UI reflects it immediately.
288
+ # Fetch existing meta first so put_item (full-doc replace on DynamoDB)
289
+ # doesn't wipe Name, MessageCount, Project, CreatedAt, etc.
290
+ sk = f"THREAD#{thread_id}#META"
291
+ meta = db.get_thread_meta(thread_id) or {}
292
+ db.put_item(sk, {**meta, "Status": "killed", "LastActivity": _now_iso()})
293
+
294
+ # Summarization is dispatched by the kill-thread-trigger PostToolUse hook
295
+ # (hooks/kill-thread-trigger.sh → hooks.dispatch_kill_thread_summary).
296
+ # Do NOT also create_task here — that would cause double-summarization.
297
+ _broadcast_thread_update()
298
+ return {
299
+ "status": "killing",
300
+ "thread_id": thread_id,
301
+ "message": "Summarization in progress",
302
+ }
303
+
304
+
305
+ @mcp.tool()
306
+ def save_memory(thread_id: str, fact: str, fact_type: str | None = None) -> dict:
307
+ """
308
+ Save a memory fact for a thread, optionally tagged by category.
309
+
310
+ Args:
311
+ thread_id: Thread this fact belongs to.
312
+ fact: Compact fact to persist.
313
+ fact_type: Optional category such as "code_index".
314
+ """
315
+ if not fact or not fact.strip():
316
+ raise ToolError("Fact must not be empty")
317
+ metadata = {"type": fact_type} if fact_type else None
318
+ memory_id = _get_memory_client().index_memory(
319
+ thread_id=thread_id,
320
+ fact=fact,
321
+ metadata=metadata,
322
+ )
323
+ return {"status": "saved", "thread_id": thread_id, "memory_id": memory_id, "type": fact_type}
324
+
325
+
326
+ @mcp.tool()
327
+ def recall_memory(thread_id: str, query: str, top_k: int = 5, fact_type: str | None = None) -> dict:
328
+ """
329
+ Semantically search thread memory using OpenSearch vector similarity.
330
+ Returns the top-k most relevant memory facts for the given query.
331
+
332
+ Args:
333
+ thread_id: The thread to search within.
334
+ query: Natural-language search query.
335
+ top_k: Maximum number of results to return (default: 5).
336
+ fact_type: Optional category filter, e.g. "code_index" to retrieve only
337
+ structural code facts instead of conversation memory.
338
+ """
339
+ if not query or not query.strip():
340
+ raise ToolError("Query must not be empty")
341
+ if top_k <= 0:
342
+ raise ToolError("top_k must be a positive integer")
343
+
344
+ client = _get_memory_client()
345
+ results = client.search_memory(thread_id=thread_id, query=query, k=top_k, type=fact_type)
346
+ return {"results": results}
347
+
348
+
349
+ @mcp.tool()
350
+ def list_threads() -> dict:
351
+ """
352
+ List all threads with their status, name, and last-activity timestamp.
353
+ Returns threads sorted by last activity descending.
354
+ """
355
+ db = _get_db()
356
+
357
+ all_items = db.query_all("THREAD#")
358
+ meta_items = [i for i in all_items if i.get("SK", "").endswith("#META")]
359
+
360
+ threads = []
361
+ for item in meta_items:
362
+ sk = item.get("SK", "")
363
+ thread_id = sk.removeprefix("THREAD#").removesuffix("#META")
364
+ threads.append({
365
+ "thread_id": thread_id,
366
+ "name": item.get("Name", ""),
367
+ "status": item.get("Status", "active").lower(),
368
+ "last_activity": item.get("LastActivity", item.get("UpdatedAt", "")),
369
+ "message_count": int(item.get("MessageCount", 0)),
370
+ "project": item.get("Project", ""),
371
+ })
372
+
373
+ threads.sort(key=lambda t: t["last_activity"], reverse=True)
374
+ return {"threads": threads}
@@ -0,0 +1,38 @@
1
+ """
2
+ server/tools/ui.py — MCP tool for opening the Thread Visualizer UI.
3
+ FR43: /open-threads-ui skill calls this tool to open localhost:8765.
4
+ """
5
+ import webbrowser
6
+
7
+ from ._mcp import mcp
8
+
9
+
10
+ def _ui_url() -> str:
11
+ from ..ui_server import PORT
12
+ return f"http://localhost:{PORT}"
13
+
14
+
15
+ def _open_ui_browser() -> str:
16
+ """
17
+ Open the Thread Visualizer in the default browser.
18
+ Always opens if ui_available; returns a status string.
19
+ """
20
+ from ..ui_server import ui_available, PORT, PORT_RANGE
21
+ url = f"http://localhost:{PORT}"
22
+ if not ui_available:
23
+ return (
24
+ f"Thread Visualizer could not start — all ports "
25
+ f"{PORT_RANGE[0]}–{PORT_RANGE[1]-1} were in use. "
26
+ "MCP tools are unaffected. Free a port and restart the plugin."
27
+ )
28
+ webbrowser.open(url)
29
+ return f"Thread Visualizer open at {url}"
30
+
31
+
32
+ @mcp.tool()
33
+ def open_threads_ui() -> str:
34
+ """
35
+ Open the TYLOR Thread Visualizer in the system default browser.
36
+ Returns a confirmation message, or a warning if the UI server is unavailable.
37
+ """
38
+ return _open_ui_browser()