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,359 @@
1
+ """
2
+ server/storage/json_store.py — Local JSON storage for agent101 threads.
3
+
4
+ Zero-infra default: all state lives in ~/.tylor/threads.json.
5
+ No database, no cloud account, no configuration required.
6
+ All writes are atomic (write-to-tmp then os.replace).
7
+
8
+ This is the default storage backend. DynamoDB is optional for multi-machine sync.
9
+ """
10
+ from __future__ import annotations
11
+ import json
12
+ import logging
13
+ import os
14
+ import uuid
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+
18
+ from mcp.server.fastmcp.exceptions import ToolError
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ STORE_VERSION = "1.0"
23
+ WARN_THRESHOLD = 400 * 1024 # 400 KB
24
+
25
+ _EMPTY_STORE: dict = {"version": STORE_VERSION, "threads": [], "current_thread_id": None}
26
+
27
+
28
+ def _now_iso() -> str:
29
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
30
+
31
+
32
+ def _default_store_path() -> Path:
33
+ return Path.home() / ".tylor" / "threads.json"
34
+
35
+
36
+ class JsonStore:
37
+ """
38
+ Local JSON storage implementing the same interface as DynamoClient
39
+ so tools can use either backend transparently via _get_db().
40
+
41
+ Thread data structure:
42
+ {id, name, status, created_at, updated_at, messages[], summary,
43
+ sandbox_roots[], current: bool, agent_states{}, agent_outputs[]}
44
+ """
45
+
46
+ def __init__(self, path: Path | None = None) -> None:
47
+ self.path = path or _default_store_path()
48
+
49
+ # ── Internal load/save ─────────────────────────────────────────────
50
+
51
+ def _load(self) -> dict:
52
+ import copy
53
+ if not self.path.exists():
54
+ return copy.deepcopy(_EMPTY_STORE)
55
+ try:
56
+ data = json.loads(self.path.read_text(encoding="utf-8"))
57
+ data.setdefault("threads", [])
58
+ data.setdefault("current_thread_id", None)
59
+ return data
60
+ except (json.JSONDecodeError, OSError) as exc:
61
+ logger.warning("Could not load %s: %s", self.path, exc)
62
+ import copy
63
+ return copy.deepcopy(_EMPTY_STORE)
64
+
65
+ # Public alias — tests call store.load()
66
+ def load(self) -> dict:
67
+ return self._load()
68
+
69
+ def _save(self, data: dict) -> None:
70
+ self.path.parent.mkdir(parents=True, exist_ok=True)
71
+ raw = json.dumps(data, indent=2, ensure_ascii=False)
72
+ if len(raw.encode()) > WARN_THRESHOLD:
73
+ logger.warning(
74
+ "Thread store approaching file size limit (%d KB)",
75
+ len(raw.encode()) // 1024,
76
+ )
77
+ tmp = self.path.with_suffix(".tmp")
78
+ try:
79
+ tmp.write_text(raw, encoding="utf-8")
80
+ os.replace(tmp, self.path)
81
+ except OSError:
82
+ tmp.unlink(missing_ok=True)
83
+ raise
84
+
85
+ def _find(self, data: dict, thread_id: str) -> dict | None:
86
+ for t in data["threads"]:
87
+ if t["id"] == thread_id:
88
+ return t
89
+ return None
90
+
91
+ def _require(self, data: dict, thread_id: str) -> dict:
92
+ t = self._find(data, thread_id)
93
+ if t is None:
94
+ raise KeyError(f"Thread not found: {thread_id}")
95
+ return t
96
+
97
+ # ── Thread CRUD (high-level API used by JsonStore tests) ───────────
98
+
99
+ def new_thread(self, name: str) -> dict:
100
+ data = self._load()
101
+ now = _now_iso()
102
+ thread: dict = {
103
+ "id": f"thread_{uuid.uuid4().hex}",
104
+ "name": name, "status": "active",
105
+ "created_at": now, "updated_at": now,
106
+ "messages": [], "summary": None,
107
+ "sandbox_roots": [], "agent_states": {}, "agent_outputs": [],
108
+ }
109
+ data["threads"].append(thread)
110
+ self._save(data)
111
+ return thread
112
+
113
+ def get_thread(self, thread_id: str) -> dict | None:
114
+ return self._find(self._load(), thread_id)
115
+
116
+ def update_thread(self, thread_id: str, **fields) -> dict:
117
+ data = self._load()
118
+ t = self._require(data, thread_id)
119
+ fields["updated_at"] = _now_iso()
120
+ t.update(fields)
121
+ self._save(data)
122
+ return t
123
+
124
+ def delete_thread(self, thread_id: str) -> bool:
125
+ data = self._load()
126
+ before = len(data["threads"])
127
+ data["threads"] = [t for t in data["threads"] if t["id"] != thread_id]
128
+ if len(data["threads"]) == before:
129
+ return False
130
+ self._save(data)
131
+ return True
132
+
133
+ def list_threads(self) -> list:
134
+ data = self._load()
135
+ return sorted(data["threads"], key=lambda t: t.get("updated_at", ""), reverse=True)
136
+
137
+ # ── DynamoClient-compatible interface ──────────────────────────────
138
+ # All tool code calls these methods via _get_db(). Keeping the same
139
+ # signatures means zero changes needed in tools/*.py
140
+
141
+ def put_item(self, sk: str, attributes: dict) -> dict:
142
+ """Store a raw item by SK. Used for messages, summaries, events."""
143
+ data = self._load()
144
+ now = _now_iso()
145
+ item = dict(attributes)
146
+ item["SK"] = sk
147
+ item.setdefault("CreatedAt", now)
148
+ item["UpdatedAt"] = now
149
+
150
+ # Route by SK pattern
151
+ if "#META" in sk:
152
+ thread_id = sk.split("#")[1] if sk.startswith("THREAD#") else None
153
+ if thread_id and thread_id != "CURRENT":
154
+ existing = self._find(data, thread_id)
155
+ if existing:
156
+ existing.update({
157
+ "name": item.get("Name", existing.get("name", "")),
158
+ "status": item.get("Status", existing.get("status", "active")).lower(),
159
+ "updated_at": now,
160
+ **({"project": item["Project"]} if item.get("Project") else {}),
161
+ })
162
+ if "Summary" in item:
163
+ existing["summary"] = item["Summary"]
164
+ self._save(data)
165
+ return item
166
+ else:
167
+ thread = {
168
+ "id": thread_id, "name": item.get("Name", ""),
169
+ "status": item.get("Status", "active").lower(),
170
+ "project": item.get("Project", ""),
171
+ "created_at": item.get("CreatedAt", now),
172
+ "updated_at": now, "messages": [], "summary": None,
173
+ "sandbox_roots": [], "agent_states": {}, "agent_outputs": [],
174
+ }
175
+ data["threads"].append(thread)
176
+ self._save(data)
177
+ return item
178
+ elif thread_id == "CURRENT":
179
+ data["current_thread_id"] = item.get("CurrentThreadId")
180
+ data["current_thread_active_at"] = item.get("ActiveAt", now)
181
+ self._save(data)
182
+ return item
183
+ elif "#MSG#" in sk or "#SUMMARY_FAILURE" in sk or "#RECOVERY" in sk or "#SANDBOX" in sk:
184
+ parts = sk.split("#")
185
+ if len(parts) >= 2:
186
+ thread_id = parts[1]
187
+ t = self._find(data, thread_id)
188
+ if t:
189
+ t.setdefault("messages", [])
190
+ item["SK"] = sk
191
+ t["messages"].append(item)
192
+ t["updated_at"] = now
193
+ self._save(data)
194
+ return item
195
+ elif "#SUMMARY" in sk and "#SUMMARY_FAILURE" not in sk:
196
+ parts = sk.split("#")
197
+ thread_id = parts[1] if len(parts) >= 2 else None
198
+ if thread_id:
199
+ t = self._find(data, thread_id)
200
+ if t:
201
+ t["summary"] = item.get("Summary", "")
202
+ t["summary_type"] = item.get("SummaryType", "")
203
+ t["updated_at"] = now
204
+ self._save(data)
205
+ return item
206
+
207
+ # Fallback: store in misc bucket
208
+ data.setdefault("misc", [])
209
+ data["misc"].append(item)
210
+ self._save(data)
211
+ return item
212
+
213
+ def get_item(self, thread_id: str, sk: str) -> dict | None:
214
+ data = self._load()
215
+ t = self._find(data, thread_id)
216
+ if not t:
217
+ return None
218
+ for msg in t.get("messages", []):
219
+ if msg.get("SK") == sk:
220
+ return msg
221
+ return None
222
+
223
+ def query_all(self, sk_prefix: str) -> list:
224
+ """Return all items whose SK starts with sk_prefix."""
225
+ data = self._load()
226
+ results = []
227
+ # Thread META items
228
+ if sk_prefix.startswith("THREAD#"):
229
+ parts = sk_prefix.split("#")
230
+ if len(parts) >= 2 and parts[1]:
231
+ thread_id = parts[1]
232
+ t = self._find(data, thread_id)
233
+ if t:
234
+ results.extend(self._thread_to_items(t))
235
+ else:
236
+ # All threads
237
+ for t in data["threads"]:
238
+ results.extend(self._thread_to_items(t))
239
+ return results
240
+
241
+ def _thread_to_items(self, t: dict) -> list:
242
+ """Convert thread dict to DynamoDB-style item list."""
243
+ items = [{
244
+ "SK": f"THREAD#{t['id']}#META",
245
+ "Name": t.get("name", ""),
246
+ "Status": t.get("status", "active").capitalize(),
247
+ "LastActivity": t.get("updated_at", ""),
248
+ "MessageCount": len(t.get("messages", [])),
249
+ "CreatedAt": t.get("created_at", ""),
250
+ "UpdatedAt": t.get("updated_at", ""),
251
+ "Project": t.get("project", ""),
252
+ }]
253
+ for msg in t.get("messages", []):
254
+ items.append(msg)
255
+ return items
256
+
257
+ def query_thread(self, thread_id: str, sk_prefix: str) -> list:
258
+ data = self._load()
259
+ t = self._find(data, thread_id)
260
+ if not t:
261
+ return []
262
+ return [m for m in t.get("messages", []) if m.get("SK", "").startswith(sk_prefix)]
263
+
264
+ def get_thread_meta(self, thread_id: str) -> dict | None:
265
+ data = self._load()
266
+ t = self._find(data, thread_id)
267
+ if not t:
268
+ return None
269
+ return {
270
+ "SK": f"THREAD#{thread_id}#META",
271
+ "Name": t.get("name", ""),
272
+ "Status": t.get("status", "active").capitalize(),
273
+ "LastActivity": t.get("updated_at", ""),
274
+ "MessageCount": len(t.get("messages", [])),
275
+ "CreatedAt": t.get("created_at", ""),
276
+ "UpdatedAt": t.get("updated_at", ""),
277
+ }
278
+
279
+ def get_current_thread_marker(self) -> dict | None:
280
+ data = self._load()
281
+ tid = data.get("current_thread_id")
282
+ if not tid:
283
+ return None
284
+ return {
285
+ "CurrentThreadId": tid,
286
+ "ActiveAt": data.get("current_thread_active_at", ""),
287
+ }
288
+
289
+ def resolve_thread_id(self, thread_id: str | None = None) -> str:
290
+ if thread_id:
291
+ return thread_id
292
+ data = self._load()
293
+ tid = data.get("current_thread_id")
294
+ if not tid:
295
+ raise ToolError("No active thread — create one with CT [name]")
296
+ return tid
297
+
298
+ def switch_thread(self, thread_id: str) -> dict:
299
+ """Make thread_id the active thread. Atomic in JSON via single save."""
300
+ data = self._load()
301
+ t = self._find(data, thread_id)
302
+ if not t:
303
+ raise ToolError(f"Thread not found: {thread_id}")
304
+ old_id = data.get("current_thread_id")
305
+ if old_id and old_id != thread_id:
306
+ old = self._find(data, old_id)
307
+ if old:
308
+ old["updated_at"] = _now_iso()
309
+ data["current_thread_id"] = thread_id
310
+ data["current_thread_active_at"] = _now_iso()
311
+ t["updated_at"] = _now_iso()
312
+ self._save(data)
313
+ return {"status": "switched", "thread_id": thread_id, "switched_at": _now_iso()}
314
+
315
+ def set_sandbox_roots(self, thread_id: str, sandbox_roots: list) -> dict:
316
+ data = self._load()
317
+ t = self._require(data, thread_id)
318
+ t["sandbox_roots"] = sandbox_roots
319
+ t["updated_at"] = _now_iso()
320
+ self._save(data)
321
+ return {"thread_id": thread_id, "sandbox_roots": sandbox_roots}
322
+
323
+ # ── Agent state (stub — works for single-machine use) ──────────────
324
+
325
+ def put_agent_output(self, thread_id: str, agent_id: str, output: str, task: str | None = None) -> dict:
326
+ data = self._load()
327
+ t = self._require(data, thread_id)
328
+ now = _now_iso()
329
+ item = {"SK": f"THREAD#{thread_id}#AGENT#{agent_id}#OUT#{now}", "ThreadId": thread_id, "AgentId": agent_id, "Output": output, "Task": task}
330
+ t.setdefault("agent_outputs", []).append(item)
331
+ t["updated_at"] = now
332
+ self._save(data)
333
+ return item
334
+
335
+ def put_agent_handoff(self, thread_id: str, agent_id: str, handoff_state: dict) -> dict:
336
+ data = self._load()
337
+ t = self._require(data, thread_id)
338
+ now = _now_iso()
339
+ item = {"SK": f"THREAD#{thread_id}#AGENT#{agent_id}#HANDOFF#{now}", "ThreadId": thread_id, "AgentId": agent_id, "HandoffState": handoff_state}
340
+ t.setdefault("agent_outputs", []).append(item)
341
+ t["updated_at"] = now
342
+ self._save(data)
343
+ return item
344
+
345
+ def put_agent_state(self, thread_id: str, agent_id: str, state: dict) -> dict:
346
+ data = self._load()
347
+ t = self._require(data, thread_id)
348
+ t.setdefault("agent_states", {})[agent_id] = {**state, "UpdatedAt": _now_iso()}
349
+ t["updated_at"] = _now_iso()
350
+ self._save(data)
351
+ sk = f"THREAD#{thread_id}#AGENT#{agent_id}#STATE"
352
+ return {"SK": sk, "ThreadId": thread_id, "AgentId": agent_id, **state}
353
+
354
+ def query_agent_states(self, thread_id: str) -> list:
355
+ data = self._load()
356
+ t = self._find(data, thread_id)
357
+ if not t:
358
+ return []
359
+ return [{"AgentId": aid, **s} for aid, s in t.get("agent_states", {}).items()]
@@ -0,0 +1,194 @@
1
+ """
2
+ server/storage/opensearch.py — OpenSearch vector client for agent101.
3
+
4
+ Handles semantic memory: embedding via Amazon Titan Embeddings v2 (1536-dim)
5
+ through AWS Bedrock, then k-NN search against the agent-memories index.
6
+
7
+ Thread isolation is enforced at query time via a term filter on thread_id.
8
+ Facts from other threads are NEVER returned.
9
+ """
10
+ from __future__ import annotations
11
+ import json
12
+ import logging
13
+ import uuid
14
+ from datetime import datetime, timezone
15
+
16
+ import boto3
17
+ from mcp.server.fastmcp.exceptions import ToolError
18
+ from opensearchpy import OpenSearch
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ INDEX = "agent-memories"
23
+ TITAN_MODEL = "amazon.titan-embed-text-v2:0"
24
+ VECTOR_DIM = 1536
25
+
26
+
27
+ def _now_iso() -> str:
28
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
29
+
30
+
31
+ class OpenSearchClient:
32
+ """
33
+ Semantic memory client backed by OpenSearch k-NN + Bedrock Titan Embeddings v2.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ host: str,
39
+ port: int = 9200,
40
+ bedrock_region: str = "us-east-1",
41
+ profile: str | None = None,
42
+ ) -> None:
43
+ self.host = host
44
+ self.port = port
45
+
46
+ self._os = OpenSearch(
47
+ hosts=[{"host": host, "port": port}],
48
+ http_compress=True,
49
+ )
50
+
51
+ session_kwargs: dict = {}
52
+ if profile:
53
+ session_kwargs["profile_name"] = profile
54
+ session = boto3.Session(**session_kwargs)
55
+ self._bedrock = session.client("bedrock-runtime", region_name=bedrock_region)
56
+
57
+ # ------------------------------------------------------------------
58
+ # Embedding helper
59
+ # ------------------------------------------------------------------
60
+
61
+ def _embed(self, text: str) -> list:
62
+ """Embed text using Titan Embeddings v2. Returns 1536-dim float list."""
63
+ try:
64
+ response = self._bedrock.invoke_model(
65
+ modelId=TITAN_MODEL,
66
+ body=json.dumps({"inputText": text}),
67
+ contentType="application/json",
68
+ accept="application/json",
69
+ )
70
+ embedding = json.loads(response["body"].read())["embedding"]
71
+ except Exception as exc:
72
+ raise ToolError(f"Bedrock embedding failed: {exc}") from exc
73
+
74
+ if len(embedding) != VECTOR_DIM:
75
+ raise ToolError(
76
+ f"Unexpected embedding dimension {len(embedding)} (expected {VECTOR_DIM})"
77
+ )
78
+ return embedding
79
+
80
+ # ------------------------------------------------------------------
81
+ # Public API
82
+ # ------------------------------------------------------------------
83
+
84
+ def index_memory(
85
+ self,
86
+ thread_id: str,
87
+ fact: str,
88
+ metadata: dict | None = None,
89
+ ) -> str:
90
+ """
91
+ Embed fact via Titan v2 and write to agent-memories index.
92
+
93
+ Args:
94
+ thread_id: Thread this fact belongs to (stored for isolation filtering).
95
+ fact: Plain-text fact to embed and store.
96
+ metadata: Optional extra fields stored alongside the fact.
97
+
98
+ Returns:
99
+ OpenSearch document ID.
100
+ """
101
+ embedding = self._embed(fact)
102
+ doc_id = uuid.uuid4().hex
103
+
104
+ doc = {
105
+ "thread_id": thread_id,
106
+ "content": fact,
107
+ "embedding": embedding,
108
+ "created_at": _now_iso(),
109
+ }
110
+ if metadata:
111
+ doc.update(metadata)
112
+
113
+ try:
114
+ self._os.index(index=INDEX, id=doc_id, body=doc, refresh=True)
115
+ except Exception as exc:
116
+ raise ToolError(f"OpenSearch index_memory failed: {exc}") from exc
117
+
118
+ logger.debug("index_memory: doc_id=%s thread=%s", doc_id, thread_id)
119
+ return doc_id
120
+
121
+ def search_memory(
122
+ self,
123
+ thread_id: str,
124
+ query: str,
125
+ k: int = 5,
126
+ type: str | None = None,
127
+ ) -> list:
128
+ """
129
+ k-NN search scoped to thread_id. Never returns other threads' facts.
130
+
131
+ Args:
132
+ thread_id: Only return facts belonging to this thread.
133
+ query: Natural-language query string to embed and search.
134
+ k: Max results to return.
135
+
136
+ Returns:
137
+ List of dicts with keys: id, content, thread_id, created_at, score.
138
+ """
139
+ query_vec = self._embed(query)
140
+
141
+ must = [
142
+ {
143
+ "knn": {
144
+ "embedding": {
145
+ "vector": query_vec,
146
+ "k": k,
147
+ }
148
+ }
149
+ },
150
+ {"term": {"thread_id": thread_id}},
151
+ ]
152
+ if type:
153
+ must.append({"term": {"type": type}})
154
+
155
+ os_query = {
156
+ "size": k,
157
+ "query": {
158
+ "bool": {
159
+ "must": must
160
+ }
161
+ },
162
+ "_source": {"excludes": ["embedding"]},
163
+ }
164
+
165
+ try:
166
+ response = self._os.search(index=INDEX, body=os_query)
167
+ except Exception as exc:
168
+ raise ToolError(f"OpenSearch search_memory failed: {exc}") from exc
169
+
170
+ hits = response.get("hits", {}).get("hits", [])
171
+ results = []
172
+ for hit in hits:
173
+ src = hit.get("_source", {})
174
+ # Enforce isolation at result layer too (defence in depth)
175
+ if src.get("thread_id") != thread_id:
176
+ logger.warning(
177
+ "search_memory: skipping result with wrong thread_id '%s' (expected '%s')",
178
+ src.get("thread_id"),
179
+ thread_id,
180
+ )
181
+ continue
182
+ results.append(
183
+ {
184
+ "id": hit["_id"],
185
+ "content": src.get("content", ""),
186
+ "thread_id": src.get("thread_id"),
187
+ "created_at": src.get("created_at"),
188
+ "type": src.get("type"),
189
+ "last_used_at": src.get("last_used_at"),
190
+ "score": hit.get("_score"),
191
+ }
192
+ )
193
+
194
+ return results
@@ -0,0 +1,96 @@
1
+ """
2
+ server/storage/s3.py — S3 blob storage client for agent101.
3
+
4
+ Handles content >400KB that cannot fit in a single DynamoDB item.
5
+ All blobs stored at: s3://{bucket}/{user_id}/threads/{thread_id}/{key}
6
+
7
+ Thread isolation: the key path embeds thread_id — cross-thread access
8
+ requires an explicit different thread_id, making accidental bleed impossible.
9
+ """
10
+ from __future__ import annotations
11
+ import logging
12
+ from urllib.parse import urlparse
13
+
14
+ import boto3
15
+ from mcp.server.fastmcp.exceptions import ToolError
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def _s3_path(user_id: str, thread_id: str, key: str) -> str:
21
+ return f"{user_id}/threads/{thread_id}/{key}"
22
+
23
+
24
+ class S3Client:
25
+ """
26
+ Typed S3 client for agent101 blob storage.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ bucket: str,
32
+ user_id: str = "default",
33
+ profile: str | None = None,
34
+ ) -> None:
35
+ self.bucket = bucket
36
+ self.user_id = user_id
37
+
38
+ session_kwargs: dict = {}
39
+ if profile:
40
+ session_kwargs["profile_name"] = profile
41
+
42
+ session = boto3.Session(**session_kwargs)
43
+ self._s3 = session.client("s3")
44
+
45
+ def put_blob(self, thread_id: str, key: str, content: str | bytes) -> str:
46
+ """
47
+ Upload content to S3. Returns the s3:// URI.
48
+
49
+ Args:
50
+ thread_id: Thread this blob belongs to. Embedded in the S3 path.
51
+ key: Blob key within the thread (e.g. "summary", "msg_001").
52
+ content: String or bytes to store.
53
+
54
+ Returns:
55
+ s3://{bucket}/{user_id}/threads/{thread_id}/{key}
56
+ """
57
+ if isinstance(content, str):
58
+ content = content.encode("utf-8")
59
+
60
+ s3_key = _s3_path(self.user_id, thread_id, key)
61
+
62
+ try:
63
+ self._s3.put_object(
64
+ Bucket=self.bucket,
65
+ Key=s3_key,
66
+ Body=content,
67
+ )
68
+ except Exception as exc:
69
+ raise ToolError(f"S3 put_blob failed for '{key}': {exc}") from exc
70
+
71
+ uri = f"s3://{self.bucket}/{s3_key}"
72
+ logger.debug("put_blob: %s (%d bytes)", uri, len(content))
73
+ return uri
74
+
75
+ def get_blob(self, uri: str) -> bytes:
76
+ """
77
+ Download a blob by its s3:// URI. Returns raw bytes.
78
+
79
+ Args:
80
+ uri: s3://{bucket}/{key} URI returned by put_blob.
81
+
82
+ Returns:
83
+ Raw bytes content.
84
+ """
85
+ parsed = urlparse(uri)
86
+ if parsed.scheme != "s3":
87
+ raise ToolError(f"Invalid S3 URI scheme: '{uri}' (expected s3://...)")
88
+
89
+ bucket = parsed.netloc
90
+ key = parsed.path.lstrip("/")
91
+
92
+ try:
93
+ response = self._s3.get_object(Bucket=bucket, Key=key)
94
+ return response["Body"].read()
95
+ except Exception as exc:
96
+ raise ToolError(f"S3 get_blob failed for '{uri}': {exc}") from exc
File without changes