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,117 @@
|
|
|
1
|
+
"""
|
|
2
|
+
server/tools/router.py — model routing with transparent overflow fallback.
|
|
3
|
+
|
|
4
|
+
Routes normal message calls through the primary Claude client. If the primary
|
|
5
|
+
route hits a rate limit, retries the identical request through Claude Platform
|
|
6
|
+
on AWS. context_length_exceeded is not retried — the identical payload would
|
|
7
|
+
fail on the platform route too.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any, Mapping
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
from mcp.shared.exceptions import McpError
|
|
18
|
+
from mcp.types import ErrorData, INTERNAL_ERROR
|
|
19
|
+
|
|
20
|
+
OVERFLOW_ERROR_TYPES = {"rate_limit_error"}
|
|
21
|
+
AWS_PLATFORM_BASE_URL_TEMPLATE = "https://aws-external-anthropic.{region}.api.aws"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _error_type(exc: BaseException) -> str:
|
|
25
|
+
"""Extract Anthropic-style error type from SDK or API exceptions."""
|
|
26
|
+
direct_type = getattr(exc, "type", None)
|
|
27
|
+
if isinstance(direct_type, str):
|
|
28
|
+
return direct_type
|
|
29
|
+
|
|
30
|
+
error = getattr(exc, "error", None)
|
|
31
|
+
nested_type = getattr(error, "type", None)
|
|
32
|
+
if isinstance(nested_type, str):
|
|
33
|
+
return nested_type
|
|
34
|
+
if isinstance(error, Mapping):
|
|
35
|
+
mapped_type = error.get("type")
|
|
36
|
+
if isinstance(mapped_type, str):
|
|
37
|
+
return mapped_type
|
|
38
|
+
|
|
39
|
+
message = str(exc).lower()
|
|
40
|
+
for error_type in OVERFLOW_ERROR_TYPES:
|
|
41
|
+
if error_type in message:
|
|
42
|
+
return error_type
|
|
43
|
+
return ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_overflow_error(exc: BaseException) -> bool:
|
|
47
|
+
"""Return True only for errors that should trigger Platform on AWS fallback."""
|
|
48
|
+
return _error_type(exc) in OVERFLOW_ERROR_TYPES
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _internal_error(message: str) -> McpError:
|
|
52
|
+
return McpError(ErrorData(code=INTERNAL_ERROR, message=message))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class ModelRouter:
|
|
57
|
+
"""Message router with primary route and Claude Platform on AWS fallback."""
|
|
58
|
+
|
|
59
|
+
primary_client: Any
|
|
60
|
+
platform_client: Any | None = None
|
|
61
|
+
|
|
62
|
+
def create_message(self, **request: Any) -> Any:
|
|
63
|
+
"""
|
|
64
|
+
Create a Claude message through primary route, falling back only for
|
|
65
|
+
rate-limit errors. The fallback receives the identical request kwargs.
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
return self.primary_client.messages.create(**request)
|
|
69
|
+
except Exception as primary_exc:
|
|
70
|
+
if not is_overflow_error(primary_exc):
|
|
71
|
+
raise
|
|
72
|
+
if self.platform_client is None:
|
|
73
|
+
from server.config import config
|
|
74
|
+
if not config.get("platform_key"):
|
|
75
|
+
logger.warning(
|
|
76
|
+
"platform_client is null — ANTHROPIC_PLATFORM_AWS_API_KEY not configured; "
|
|
77
|
+
"overflow fallback unavailable"
|
|
78
|
+
)
|
|
79
|
+
raise _internal_error("All model routes exhausted") from primary_exc
|
|
80
|
+
try:
|
|
81
|
+
platform = self.platform_client or build_platform_client()
|
|
82
|
+
self.platform_client = platform
|
|
83
|
+
return platform.messages.create(**request)
|
|
84
|
+
except Exception as platform_exc:
|
|
85
|
+
raise _internal_error("All model routes exhausted") from platform_exc
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def build_platform_base_url(region: str) -> str:
|
|
89
|
+
"""Build the regional Claude Platform on AWS endpoint."""
|
|
90
|
+
return AWS_PLATFORM_BASE_URL_TEMPLATE.format(region=region)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def build_platform_client() -> Any:
|
|
94
|
+
"""Construct an Anthropic SDK client for Claude Platform on AWS."""
|
|
95
|
+
from anthropic import Anthropic
|
|
96
|
+
from server.config import config
|
|
97
|
+
|
|
98
|
+
platform_key = config.get("platform_key")
|
|
99
|
+
if not platform_key:
|
|
100
|
+
raise RuntimeError("ANTHROPIC_PLATFORM_AWS_API_KEY not configured")
|
|
101
|
+
|
|
102
|
+
base_url = config.get("platform_base_url") or build_platform_base_url(
|
|
103
|
+
config.get("bedrock_region", "us-east-1")
|
|
104
|
+
)
|
|
105
|
+
workspace_id = config.get("platform_workspace_id")
|
|
106
|
+
headers = {"anthropic-workspace-id": workspace_id} if workspace_id else None
|
|
107
|
+
|
|
108
|
+
return Anthropic(
|
|
109
|
+
api_key=platform_key,
|
|
110
|
+
base_url=base_url,
|
|
111
|
+
default_headers=headers,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def create_message(primary_client: Any, **request: Any) -> Any:
|
|
116
|
+
"""Convenience wrapper for call sites that do not need a router instance."""
|
|
117
|
+
return ModelRouter(primary_client=primary_client).create_message(**request)
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Skill package installer for the /add-skill command."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import argparse
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from mcp.shared.exceptions import McpError
|
|
11
|
+
from mcp.server.fastmcp.exceptions import ToolError
|
|
12
|
+
from mcp.types import ErrorData, INVALID_PARAMS
|
|
13
|
+
|
|
14
|
+
from ._mcp import mcp
|
|
15
|
+
|
|
16
|
+
PLUGIN_DIR = Path(__file__).resolve().parents[2]
|
|
17
|
+
DEFAULT_SKILLS_DIR = PLUGIN_DIR / "skills"
|
|
18
|
+
DEFAULT_REGISTRY_PATH = PLUGIN_DIR / "registry.json"
|
|
19
|
+
|
|
20
|
+
_STOPWORDS = {
|
|
21
|
+
"and",
|
|
22
|
+
"for",
|
|
23
|
+
"the",
|
|
24
|
+
"this",
|
|
25
|
+
"that",
|
|
26
|
+
"use",
|
|
27
|
+
"user",
|
|
28
|
+
"when",
|
|
29
|
+
"wants",
|
|
30
|
+
"with",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _invalid_params(message: str) -> McpError:
|
|
35
|
+
return McpError(ErrorData(code=INVALID_PARAMS, message=message))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _now_date() -> str:
|
|
39
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _read_registry(path: Path) -> dict:
|
|
43
|
+
if not path.exists():
|
|
44
|
+
return {"version": "1.0", "skills": []}
|
|
45
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _write_registry(path: Path, data: dict) -> None:
|
|
49
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
tmp = path.with_suffix(".tmp")
|
|
51
|
+
tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
52
|
+
tmp.replace(path)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _parse_frontmatter_value(value: str):
|
|
56
|
+
value = value.strip()
|
|
57
|
+
if value.startswith("[") and value.endswith("]"):
|
|
58
|
+
try:
|
|
59
|
+
return json.loads(value)
|
|
60
|
+
except json.JSONDecodeError:
|
|
61
|
+
pass
|
|
62
|
+
return value.strip("'\"")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _frontmatter(text: str) -> dict:
|
|
66
|
+
if not text.startswith("---\n"):
|
|
67
|
+
return {}
|
|
68
|
+
end = text.find("\n---", 4)
|
|
69
|
+
if end == -1:
|
|
70
|
+
return {}
|
|
71
|
+
data = {}
|
|
72
|
+
for line in text[4:end].splitlines():
|
|
73
|
+
if ":" not in line:
|
|
74
|
+
continue
|
|
75
|
+
key, value = line.split(":", 1)
|
|
76
|
+
data[key.strip()] = _parse_frontmatter_value(value)
|
|
77
|
+
return data
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _skill_name(source_path: Path, metadata: dict, explicit_name: str | None) -> str:
|
|
81
|
+
raw = explicit_name or metadata.get("name") or source_path.name
|
|
82
|
+
name = raw.strip().lower().replace(" ", "-").replace("_", "-")
|
|
83
|
+
if not re.match(r"^[a-z0-9][a-z0-9-]*$", name):
|
|
84
|
+
raise _invalid_params(f"Invalid skill name: {raw}")
|
|
85
|
+
return name
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _trigger_description(text: str, metadata: dict) -> str:
|
|
89
|
+
description = metadata.get("description", "").strip()
|
|
90
|
+
if description:
|
|
91
|
+
return description
|
|
92
|
+
for line in text.splitlines():
|
|
93
|
+
line = line.strip()
|
|
94
|
+
if line.lower().startswith("use when"):
|
|
95
|
+
return line
|
|
96
|
+
return "No trigger description provided."
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _keywords(name: str, trigger: str) -> list[str]:
|
|
100
|
+
words = re.findall(r"[a-z0-9]+", f"{name} {trigger}".lower())
|
|
101
|
+
seen = set()
|
|
102
|
+
keywords = []
|
|
103
|
+
for word in words:
|
|
104
|
+
if len(word) < 3 or word in _STOPWORDS or word in seen:
|
|
105
|
+
continue
|
|
106
|
+
seen.add(word)
|
|
107
|
+
keywords.append(word)
|
|
108
|
+
return keywords
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _tool_count(text: str) -> int:
|
|
112
|
+
tool_refs = set(re.findall(r"`([a-zA-Z_][a-zA-Z0-9_]+)\(", text))
|
|
113
|
+
return len(tool_refs)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _copy_skill(source_path: Path, target_path: Path, overwrite: bool) -> None:
|
|
117
|
+
if target_path.exists():
|
|
118
|
+
if not overwrite:
|
|
119
|
+
raise _invalid_params(
|
|
120
|
+
f"Skill '{target_path.name}' already exists; rerun with overwrite=True to replace it."
|
|
121
|
+
)
|
|
122
|
+
shutil.rmtree(target_path)
|
|
123
|
+
shutil.copytree(source_path, target_path)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def install_skill(
|
|
127
|
+
source_path: str | Path,
|
|
128
|
+
name: str | None = None,
|
|
129
|
+
overwrite: bool = False,
|
|
130
|
+
skills_dir: str | Path = DEFAULT_SKILLS_DIR,
|
|
131
|
+
registry_path: str | Path = DEFAULT_REGISTRY_PATH,
|
|
132
|
+
) -> dict:
|
|
133
|
+
"""Copy a skill package and upsert its generated registry entry."""
|
|
134
|
+
source = Path(source_path).expanduser().resolve()
|
|
135
|
+
skill_file = source / "SKILL.md"
|
|
136
|
+
if not skill_file.exists():
|
|
137
|
+
raise _invalid_params(f"SKILL.md not found in {source}")
|
|
138
|
+
|
|
139
|
+
text = skill_file.read_text(encoding="utf-8")
|
|
140
|
+
metadata = _frontmatter(text)
|
|
141
|
+
skill_name = _skill_name(source, metadata, name)
|
|
142
|
+
trigger = _trigger_description(text, metadata)
|
|
143
|
+
target = Path(skills_dir) / skill_name
|
|
144
|
+
registry = _read_registry(Path(registry_path))
|
|
145
|
+
skills = list(registry.get("skills", []))
|
|
146
|
+
exists = any(entry.get("name") == skill_name for entry in skills)
|
|
147
|
+
|
|
148
|
+
if exists and not overwrite:
|
|
149
|
+
raise _invalid_params(
|
|
150
|
+
f"Skill '{skill_name}' already exists in registry.json; rerun with overwrite=True to replace it."
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
_copy_skill(source, target, overwrite=overwrite)
|
|
154
|
+
|
|
155
|
+
module = metadata.get("module")
|
|
156
|
+
tools = metadata.get("tools")
|
|
157
|
+
if isinstance(tools, str):
|
|
158
|
+
tools = [tools]
|
|
159
|
+
if tools is not None and not isinstance(tools, list):
|
|
160
|
+
raise _invalid_params("Invalid tools metadata in SKILL.md; use a comma-separated list or JSON array.")
|
|
161
|
+
|
|
162
|
+
entry = {
|
|
163
|
+
"name": skill_name,
|
|
164
|
+
"trigger": trigger,
|
|
165
|
+
"trigger_description": trigger,
|
|
166
|
+
"keywords": _keywords(skill_name, trigger),
|
|
167
|
+
"tool_count": _tool_count(text),
|
|
168
|
+
"installed_date": _now_date(),
|
|
169
|
+
"source_path": str(source),
|
|
170
|
+
}
|
|
171
|
+
if module:
|
|
172
|
+
entry["module"] = str(module)
|
|
173
|
+
if tools:
|
|
174
|
+
entry["tools"] = [str(tool).strip() for tool in tools if str(tool).strip()]
|
|
175
|
+
|
|
176
|
+
registry["skills"] = [entry for entry in skills if entry.get("name") != skill_name]
|
|
177
|
+
registry["skills"].append(entry)
|
|
178
|
+
_write_registry(Path(registry_path), registry)
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
"status": "installed",
|
|
182
|
+
"name": skill_name,
|
|
183
|
+
"installed_to": str(target),
|
|
184
|
+
"registry_path": str(registry_path),
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@mcp.tool()
|
|
189
|
+
def add_skill(
|
|
190
|
+
source_path: str,
|
|
191
|
+
name: str | None = None,
|
|
192
|
+
overwrite: bool = False,
|
|
193
|
+
) -> dict:
|
|
194
|
+
"""
|
|
195
|
+
Install an agent101 skill package and update registry.json.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
source_path: Local path to a skill package directory containing SKILL.md.
|
|
199
|
+
name: Optional skill name override.
|
|
200
|
+
overwrite: Replace an existing installed skill if present.
|
|
201
|
+
"""
|
|
202
|
+
try:
|
|
203
|
+
return install_skill(
|
|
204
|
+
source_path=source_path,
|
|
205
|
+
name=name,
|
|
206
|
+
overwrite=overwrite,
|
|
207
|
+
)
|
|
208
|
+
except McpError:
|
|
209
|
+
raise
|
|
210
|
+
except Exception as exc:
|
|
211
|
+
raise ToolError(f"add_skill failed: {exc}") from exc
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def main() -> None:
|
|
215
|
+
parser = argparse.ArgumentParser(description="Install an agent101 skill package.")
|
|
216
|
+
parser.add_argument("source_path")
|
|
217
|
+
parser.add_argument("--name")
|
|
218
|
+
parser.add_argument("--overwrite", action="store_true")
|
|
219
|
+
args = parser.parse_args()
|
|
220
|
+
|
|
221
|
+
result = install_skill(
|
|
222
|
+
source_path=args.source_path,
|
|
223
|
+
name=args.name,
|
|
224
|
+
overwrite=args.overwrite,
|
|
225
|
+
)
|
|
226
|
+
print(json.dumps(result, indent=2))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
if __name__ == "__main__":
|
|
230
|
+
main()
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""
|
|
2
|
+
server/tools/summarizer.py — async thread summarization via Bedrock Opus.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import boto3
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
DEFAULT_LAST_N_MESSAGES = 20
|
|
16
|
+
DEFAULT_SUMMARY_MAX_TOKENS = 1024
|
|
17
|
+
DEFAULT_BEDROCK_OPUS_MODEL = "us.anthropic.claude-opus-4-7-20251101-v1:0"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _message_text(message: dict) -> str:
|
|
21
|
+
role = message.get("Role") or message.get("role") or "unknown"
|
|
22
|
+
content = message.get("Content") or message.get("content") or ""
|
|
23
|
+
return f"{role}: {content}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _last_messages(db: Any, thread_id: str, limit: int = DEFAULT_LAST_N_MESSAGES) -> list[dict]:
|
|
27
|
+
messages = db.query_thread(thread_id, f"THREAD#{thread_id}#MSG#")
|
|
28
|
+
messages.sort(key=lambda item: item.get("SK", ""))
|
|
29
|
+
return messages[-limit:]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _raw_fallback_summary(messages: list[dict]) -> str:
|
|
33
|
+
if not messages:
|
|
34
|
+
return "No messages were available for fallback summary."
|
|
35
|
+
return "\n".join(_message_text(message) for message in messages)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _extract_bedrock_text(response: dict) -> str:
|
|
39
|
+
body = response.get("body")
|
|
40
|
+
payload = body.read() if hasattr(body, "read") else body
|
|
41
|
+
if isinstance(payload, bytes):
|
|
42
|
+
payload = payload.decode("utf-8")
|
|
43
|
+
data = json.loads(payload)
|
|
44
|
+
|
|
45
|
+
content = data.get("content", [])
|
|
46
|
+
if isinstance(content, list):
|
|
47
|
+
parts = [
|
|
48
|
+
part.get("text", "")
|
|
49
|
+
for part in content
|
|
50
|
+
if isinstance(part, dict) and part.get("type") == "text"
|
|
51
|
+
]
|
|
52
|
+
return "".join(parts).strip()
|
|
53
|
+
if isinstance(content, str):
|
|
54
|
+
return content.strip()
|
|
55
|
+
return ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _summary_prompt(messages: list[dict]) -> str:
|
|
59
|
+
transcript = _raw_fallback_summary(messages)
|
|
60
|
+
return (
|
|
61
|
+
"Summarize this development thread for future context restoration. "
|
|
62
|
+
"Keep durable decisions, changed files, unresolved risks, and next steps.\n\n"
|
|
63
|
+
f"{transcript}"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _build_bedrock_client() -> Any:
|
|
68
|
+
from server.config import config
|
|
69
|
+
|
|
70
|
+
session_kwargs: dict[str, str] = {}
|
|
71
|
+
if config.get("aws_profile"):
|
|
72
|
+
session_kwargs["profile_name"] = config["aws_profile"]
|
|
73
|
+
session = boto3.Session(**session_kwargs)
|
|
74
|
+
return session.client(
|
|
75
|
+
"bedrock-runtime",
|
|
76
|
+
region_name=config.get("bedrock_region", "us-east-1"),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _bedrock_model_id() -> str:
|
|
81
|
+
from server.config import config
|
|
82
|
+
|
|
83
|
+
return config.get("bedrock_opus_model") or DEFAULT_BEDROCK_OPUS_MODEL
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _mark_thread_killed(db: Any, thread_id: str) -> None:
|
|
87
|
+
meta = db.get_thread_meta(thread_id) or {}
|
|
88
|
+
attributes = dict(meta)
|
|
89
|
+
attributes["Status"] = "killed"
|
|
90
|
+
db.put_item(f"THREAD#{thread_id}#META", attributes)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _write_summary(
|
|
94
|
+
db: Any,
|
|
95
|
+
thread_id: str,
|
|
96
|
+
summary: str,
|
|
97
|
+
summary_type: str,
|
|
98
|
+
error: str | None = None,
|
|
99
|
+
) -> None:
|
|
100
|
+
attributes = {
|
|
101
|
+
"Summary": summary,
|
|
102
|
+
"SummaryType": summary_type,
|
|
103
|
+
}
|
|
104
|
+
if error:
|
|
105
|
+
attributes["Error"] = error
|
|
106
|
+
db.put_item(f"THREAD#{thread_id}#SUMMARY", attributes)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _log_failure(db: Any, thread_id: str, error: str) -> None:
|
|
110
|
+
from server.tools.tylor import _now_iso
|
|
111
|
+
|
|
112
|
+
sk = f"THREAD#{thread_id}#MSG#{_now_iso()}#SUMMARY_FAILURE"
|
|
113
|
+
db.put_item(sk, {
|
|
114
|
+
"Role": "system",
|
|
115
|
+
"Content": f"kill_thread summarization failed: {error}",
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def summarize_thread(
|
|
120
|
+
thread_id: str,
|
|
121
|
+
db: Any,
|
|
122
|
+
bedrock_client: Any | None = None,
|
|
123
|
+
model_id: str | None = None,
|
|
124
|
+
last_n: int = DEFAULT_LAST_N_MESSAGES,
|
|
125
|
+
) -> None:
|
|
126
|
+
"""
|
|
127
|
+
Summarize a thread, persist the result, and mark the thread killed.
|
|
128
|
+
On Bedrock failure, stores raw last-N messages and logs the failure.
|
|
129
|
+
"""
|
|
130
|
+
messages = _last_messages(db, thread_id, last_n)
|
|
131
|
+
bedrock = bedrock_client or _build_bedrock_client()
|
|
132
|
+
model = model_id or _bedrock_model_id()
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
body = {
|
|
136
|
+
"anthropic_version": "bedrock-2023-05-31",
|
|
137
|
+
"max_tokens": DEFAULT_SUMMARY_MAX_TOKENS,
|
|
138
|
+
"messages": [
|
|
139
|
+
{
|
|
140
|
+
"role": "user",
|
|
141
|
+
"content": _summary_prompt(messages),
|
|
142
|
+
}
|
|
143
|
+
],
|
|
144
|
+
}
|
|
145
|
+
response = await asyncio.to_thread(
|
|
146
|
+
bedrock.invoke_model,
|
|
147
|
+
modelId=model,
|
|
148
|
+
body=json.dumps(body),
|
|
149
|
+
contentType="application/json",
|
|
150
|
+
accept="application/json",
|
|
151
|
+
)
|
|
152
|
+
summary = _extract_bedrock_text(response)
|
|
153
|
+
if not summary:
|
|
154
|
+
raise RuntimeError("Bedrock returned an empty summary")
|
|
155
|
+
_write_summary(db, thread_id, summary, "bedrock_opus")
|
|
156
|
+
except Exception as exc:
|
|
157
|
+
error = str(exc)
|
|
158
|
+
logger.exception("Thread summarization failed for %s", thread_id)
|
|
159
|
+
_write_summary(
|
|
160
|
+
db,
|
|
161
|
+
thread_id,
|
|
162
|
+
_raw_fallback_summary(messages),
|
|
163
|
+
"raw_fallback",
|
|
164
|
+
error=error,
|
|
165
|
+
)
|
|
166
|
+
_log_failure(db, thread_id, error)
|
|
167
|
+
finally:
|
|
168
|
+
_mark_thread_killed(db, thread_id)
|
|
File without changes
|