tamfis-code 0.2.3__py3-none-any.whl
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.
- tamfis_code/__init__.py +58 -0
- tamfis_code/__main__.py +4 -0
- tamfis_code/agents.py +371 -0
- tamfis_code/api_client.py +461 -0
- tamfis_code/cli.py +2351 -0
- tamfis_code/completion.py +137 -0
- tamfis_code/config.py +199 -0
- tamfis_code/doctor.py +173 -0
- tamfis_code/enforcer.py +318 -0
- tamfis_code/indexer.py +567 -0
- tamfis_code/instructions.py +121 -0
- tamfis_code/interactive.py +985 -0
- tamfis_code/local_chat.py +117 -0
- tamfis_code/local_tools.py +93 -0
- tamfis_code/mcp.py +538 -0
- tamfis_code/metrics.py +105 -0
- tamfis_code/providers.py +423 -0
- tamfis_code/references.py +336 -0
- tamfis_code/render.py +575 -0
- tamfis_code/runner.py +635 -0
- tamfis_code/runner_local.py +364 -0
- tamfis_code/safety.py +164 -0
- tamfis_code/screenshot.py +382 -0
- tamfis_code/sessions.py +253 -0
- tamfis_code/state.py +449 -0
- tamfis_code/tasks.py +42 -0
- tamfis_code/workspace.py +329 -0
- tamfis_code-0.2.3.dist-info/METADATA +78 -0
- tamfis_code-0.2.3.dist-info/RECORD +32 -0
- tamfis_code-0.2.3.dist-info/WHEEL +5 -0
- tamfis_code-0.2.3.dist-info/entry_points.txt +4 -0
- tamfis_code-0.2.3.dist-info/top_level.txt +1 -0
tamfis_code/mcp.py
ADDED
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
"""Model Context Protocol (MCP) integration for tools"""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Any, List, Optional, Callable
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import asyncio
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _import_monorepo_attr(module_path: str, attr: str):
|
|
14
|
+
"""Import `attr` from `module_path`, only if a monorepo (tamgpt6) checkout
|
|
15
|
+
happens to be co-located next to this standalone package -- e.g. a dev
|
|
16
|
+
running an editable install from inside tamgpt6/tamfis_code, or with
|
|
17
|
+
tamgpt6 as the current working directory.
|
|
18
|
+
|
|
19
|
+
Returns None (never raises) when the monorepo isn't present. tamfis-code
|
|
20
|
+
is an independent package with no hard dependency on tamgpt6's backend
|
|
21
|
+
modules; callers of this helper must treat None as "unavailable outside
|
|
22
|
+
a monorepo checkout" and report that clearly rather than crash.
|
|
23
|
+
"""
|
|
24
|
+
try:
|
|
25
|
+
module = __import__(module_path, fromlist=[attr])
|
|
26
|
+
return getattr(module, attr)
|
|
27
|
+
except ModuleNotFoundError:
|
|
28
|
+
pass
|
|
29
|
+
top_level_package = module_path.split(".", 1)[0]
|
|
30
|
+
candidates = [Path.cwd(), *Path(__file__).resolve().parents]
|
|
31
|
+
for root in candidates:
|
|
32
|
+
if (root / top_level_package).is_dir():
|
|
33
|
+
root_text = str(root)
|
|
34
|
+
if root_text not in sys.path:
|
|
35
|
+
sys.path.insert(0, root_text)
|
|
36
|
+
try:
|
|
37
|
+
module = __import__(module_path, fromlist=[attr])
|
|
38
|
+
return getattr(module, attr)
|
|
39
|
+
except ModuleNotFoundError:
|
|
40
|
+
continue
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _get_shared_mcp_bridge():
|
|
45
|
+
"""Load the monorepo MCP bridge, if a tamgpt6 checkout is co-located.
|
|
46
|
+
|
|
47
|
+
Returns None otherwise -- see _import_monorepo_attr's docstring.
|
|
48
|
+
"""
|
|
49
|
+
get_mcp_bridge = _import_monorepo_attr("tier_viii_infrastructure.mcp.orchestrator_bridge", "get_mcp_bridge")
|
|
50
|
+
return get_mcp_bridge() if get_mcp_bridge is not None else None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_browser_tool_class():
|
|
54
|
+
"""Load BrowserTool, if a tamgpt6 checkout is co-located, else None."""
|
|
55
|
+
return _import_monorepo_attr("tier_iv_orchestration.tools.browser_tool", "BrowserTool")
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class ToolDefinition:
|
|
59
|
+
"""Definition of a tool for MCP"""
|
|
60
|
+
name: str
|
|
61
|
+
description: str
|
|
62
|
+
parameters: Dict[str, Any] # JSON Schema
|
|
63
|
+
handler: Optional[Callable] = None
|
|
64
|
+
|
|
65
|
+
class MCPServer:
|
|
66
|
+
"""MCP server for tool execution"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, *, workspace_root: Optional[str] = None, session_id: Optional[int] = None):
|
|
69
|
+
# workspace_root/session_id are optional so existing callers that
|
|
70
|
+
# construct MCPServer() with no arguments (tests, the `tools`/
|
|
71
|
+
# `screenshot` debug commands) keep today's behaviour: no boundary
|
|
72
|
+
# enforcement on write_file/edit_file, no mutation-ledger recording.
|
|
73
|
+
# The standalone agent loop (runner_local.py) always supplies both.
|
|
74
|
+
self.workspace_root = workspace_root
|
|
75
|
+
self.session_id = session_id
|
|
76
|
+
self.tools: Dict[str, ToolDefinition] = {}
|
|
77
|
+
self._register_default_tools()
|
|
78
|
+
|
|
79
|
+
def _register_default_tools(self):
|
|
80
|
+
"""Register default tools"""
|
|
81
|
+
|
|
82
|
+
self.register_tool(
|
|
83
|
+
name="read_file",
|
|
84
|
+
description="Read contents of a file",
|
|
85
|
+
parameters={
|
|
86
|
+
"type": "object",
|
|
87
|
+
"properties": {
|
|
88
|
+
"path": {"type": "string", "description": "File path"}
|
|
89
|
+
},
|
|
90
|
+
"required": ["path"]
|
|
91
|
+
},
|
|
92
|
+
handler=self._read_file
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
self.register_tool(
|
|
96
|
+
name="write_file",
|
|
97
|
+
description="Write content to a file",
|
|
98
|
+
parameters={
|
|
99
|
+
"type": "object",
|
|
100
|
+
"properties": {
|
|
101
|
+
"path": {"type": "string", "description": "File path"},
|
|
102
|
+
"content": {"type": "string", "description": "File content"}
|
|
103
|
+
},
|
|
104
|
+
"required": ["path", "content"]
|
|
105
|
+
},
|
|
106
|
+
handler=self._write_file
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
self.register_tool(
|
|
110
|
+
name="edit_file",
|
|
111
|
+
description=(
|
|
112
|
+
"Replace an exact, unique occurrence of old_string with new_string in a file. "
|
|
113
|
+
"Fails if old_string is not found, or is not unique -- include enough surrounding "
|
|
114
|
+
"context in old_string to make the match unambiguous. Use write_file instead for "
|
|
115
|
+
"creating a brand-new file or replacing one's entire contents."
|
|
116
|
+
),
|
|
117
|
+
parameters={
|
|
118
|
+
"type": "object",
|
|
119
|
+
"properties": {
|
|
120
|
+
"path": {"type": "string", "description": "File path"},
|
|
121
|
+
"old_string": {"type": "string", "description": "Exact text to replace (must match exactly once)"},
|
|
122
|
+
"new_string": {"type": "string", "description": "Replacement text"},
|
|
123
|
+
},
|
|
124
|
+
"required": ["path", "old_string", "new_string"],
|
|
125
|
+
},
|
|
126
|
+
handler=self._edit_file,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
self.register_tool(
|
|
130
|
+
name="list_directory",
|
|
131
|
+
description="List contents of a directory",
|
|
132
|
+
parameters={
|
|
133
|
+
"type": "object",
|
|
134
|
+
"properties": {
|
|
135
|
+
"path": {"type": "string", "description": "Directory path"}
|
|
136
|
+
},
|
|
137
|
+
"required": ["path"]
|
|
138
|
+
},
|
|
139
|
+
handler=self._list_directory
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
self.register_tool(
|
|
143
|
+
name="search_code",
|
|
144
|
+
description="Search code using ripgrep",
|
|
145
|
+
parameters={
|
|
146
|
+
"type": "object",
|
|
147
|
+
"properties": {
|
|
148
|
+
"query": {"type": "string", "description": "Search pattern"},
|
|
149
|
+
"path": {"type": "string", "description": "Search directory"},
|
|
150
|
+
"file_pattern": {"type": "string", "description": "File pattern to match"}
|
|
151
|
+
},
|
|
152
|
+
"required": ["query"]
|
|
153
|
+
},
|
|
154
|
+
handler=self._search_code
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
self.register_tool(
|
|
158
|
+
name="execute_command",
|
|
159
|
+
description="Execute a shell command",
|
|
160
|
+
parameters={
|
|
161
|
+
"type": "object",
|
|
162
|
+
"properties": {
|
|
163
|
+
"command": {"type": "string", "description": "Command to execute"},
|
|
164
|
+
"timeout": {"type": "integer", "description": "Timeout in seconds"}
|
|
165
|
+
},
|
|
166
|
+
"required": ["command"]
|
|
167
|
+
},
|
|
168
|
+
handler=self._execute_command
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
self.register_tool(
|
|
172
|
+
name="get_git_info",
|
|
173
|
+
description="Get git repository information",
|
|
174
|
+
parameters={
|
|
175
|
+
"type": "object",
|
|
176
|
+
"properties": {
|
|
177
|
+
"path": {"type": "string", "description": "Repository path"}
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
handler=self._get_git_info
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
self.register_tool(
|
|
184
|
+
name="browser",
|
|
185
|
+
description=(
|
|
186
|
+
"Use a clean headless Playwright session to navigate a public page, extract or interact "
|
|
187
|
+
"with elements, test mobile scrolling, and capture a real PNG screenshot"
|
|
188
|
+
),
|
|
189
|
+
parameters={
|
|
190
|
+
"type": "object",
|
|
191
|
+
"properties": {
|
|
192
|
+
"url": {"type": "string", "description": "Absolute public http(s) URL"},
|
|
193
|
+
"action": {
|
|
194
|
+
"type": "string",
|
|
195
|
+
"enum": ["navigate", "extract", "click", "fill_form", "scroll", "screenshot"],
|
|
196
|
+
},
|
|
197
|
+
"selector": {"type": "string"},
|
|
198
|
+
"form_data": {"type": "object", "additionalProperties": {"type": "string"}},
|
|
199
|
+
"submit_selector": {"type": "string"},
|
|
200
|
+
"viewport_width": {"type": "integer", "minimum": 320, "maximum": 3840},
|
|
201
|
+
"viewport_height": {"type": "integer", "minimum": 480, "maximum": 2160},
|
|
202
|
+
"wait_for_selector": {"type": "string"},
|
|
203
|
+
"wait_after_load_ms": {"type": "integer", "minimum": 0, "maximum": 5000},
|
|
204
|
+
"scroll_y": {"type": "integer"},
|
|
205
|
+
"full_page": {"type": "boolean"},
|
|
206
|
+
"screenshot_selector": {"type": "string"},
|
|
207
|
+
"screenshot_name": {"type": "string"},
|
|
208
|
+
},
|
|
209
|
+
"required": ["url", "action"],
|
|
210
|
+
},
|
|
211
|
+
handler=self._browser,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def register_tool(self, name: str, description: str,
|
|
215
|
+
parameters: Dict[str, Any], handler: Callable):
|
|
216
|
+
"""Register a tool"""
|
|
217
|
+
self.tools[name] = ToolDefinition(
|
|
218
|
+
name=name,
|
|
219
|
+
description=description,
|
|
220
|
+
parameters=parameters,
|
|
221
|
+
handler=handler
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
def list_tools(self) -> List[Dict[str, Any]]:
|
|
225
|
+
"""List all registered tools"""
|
|
226
|
+
return [
|
|
227
|
+
{
|
|
228
|
+
"name": tool.name,
|
|
229
|
+
"description": tool.description,
|
|
230
|
+
"parameters": tool.parameters
|
|
231
|
+
}
|
|
232
|
+
for tool in self.tools.values()
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
def tool_schemas_openai(self, names: Optional[List[str]] = None) -> List[Dict[str, Any]]:
|
|
236
|
+
"""Wrap registered tools in the `{"type":"function","function":{...}}`
|
|
237
|
+
envelope a chat-completions `tools=[...]` payload needs. `names`
|
|
238
|
+
restricts to a subset (e.g. read-only tools for a lower-trust mode);
|
|
239
|
+
omit for the full registered set."""
|
|
240
|
+
selected = names if names is not None else list(self.tools.keys())
|
|
241
|
+
return [
|
|
242
|
+
{
|
|
243
|
+
"type": "function",
|
|
244
|
+
"function": {
|
|
245
|
+
"name": tool.name,
|
|
246
|
+
"description": tool.description,
|
|
247
|
+
"parameters": tool.parameters,
|
|
248
|
+
},
|
|
249
|
+
}
|
|
250
|
+
for tool in self.tools.values()
|
|
251
|
+
if tool.name in selected
|
|
252
|
+
]
|
|
253
|
+
|
|
254
|
+
async def list_tools_async(self, include_shared: bool = True) -> List[Dict[str, Any]]:
|
|
255
|
+
"""List native CLI tools and tools discovered by the shared MCP hub."""
|
|
256
|
+
tools = self.list_tools()
|
|
257
|
+
if not include_shared:
|
|
258
|
+
return tools
|
|
259
|
+
bridge = None
|
|
260
|
+
owns_bridge = False
|
|
261
|
+
try:
|
|
262
|
+
bridge = _get_shared_mcp_bridge()
|
|
263
|
+
if bridge is None:
|
|
264
|
+
raise RuntimeError("Shared MCP bridge unavailable outside a monorepo checkout")
|
|
265
|
+
if not bridge.available:
|
|
266
|
+
owns_bridge = True
|
|
267
|
+
await bridge.initialize(background=False)
|
|
268
|
+
shared = await bridge.list_tools()
|
|
269
|
+
tools.extend({**tool, "source": "shared_mcp"} for tool in shared)
|
|
270
|
+
except Exception as exc:
|
|
271
|
+
tools.append({
|
|
272
|
+
"name": "shared_mcp",
|
|
273
|
+
"description": f"Shared MCP registry unavailable: {exc}",
|
|
274
|
+
"parameters": {},
|
|
275
|
+
"available": False,
|
|
276
|
+
})
|
|
277
|
+
finally:
|
|
278
|
+
# ``tamfis-code tools list`` runs in a short asyncio.run() loop.
|
|
279
|
+
# Explicitly close any MCP processes this invocation opened; leaving
|
|
280
|
+
# their transports for loop finalization caused the CLI to hang.
|
|
281
|
+
if owns_bridge and bridge is not None:
|
|
282
|
+
await bridge.shutdown()
|
|
283
|
+
return tools
|
|
284
|
+
|
|
285
|
+
async def call_tool(self, name: str, parameters: Dict[str, Any]) -> Dict[str, Any]:
|
|
286
|
+
"""Call a tool by name"""
|
|
287
|
+
if name not in self.tools:
|
|
288
|
+
bridge = None
|
|
289
|
+
owns_bridge = False
|
|
290
|
+
try:
|
|
291
|
+
bridge = _get_shared_mcp_bridge()
|
|
292
|
+
if bridge is None:
|
|
293
|
+
raise RuntimeError("Shared MCP bridge unavailable outside a monorepo checkout")
|
|
294
|
+
if not bridge.available:
|
|
295
|
+
owns_bridge = True
|
|
296
|
+
await bridge.initialize(background=False)
|
|
297
|
+
result = await bridge.call_tool(name, parameters)
|
|
298
|
+
success = bool(result.get("success")) and not result.get("is_error")
|
|
299
|
+
return {
|
|
300
|
+
"result": result,
|
|
301
|
+
"tool": name,
|
|
302
|
+
"source": "shared_mcp",
|
|
303
|
+
"success": success,
|
|
304
|
+
**({"error": result.get("error_message") or result.get("error")}
|
|
305
|
+
if not success else {}),
|
|
306
|
+
}
|
|
307
|
+
except Exception as exc:
|
|
308
|
+
return {
|
|
309
|
+
"error": f"Shared MCP tool unavailable: {exc}",
|
|
310
|
+
"tool": name,
|
|
311
|
+
"source": "shared_mcp",
|
|
312
|
+
"success": False,
|
|
313
|
+
}
|
|
314
|
+
finally:
|
|
315
|
+
if owns_bridge and bridge is not None:
|
|
316
|
+
await bridge.shutdown()
|
|
317
|
+
|
|
318
|
+
tool = self.tools[name]
|
|
319
|
+
try:
|
|
320
|
+
result = await tool.handler(**parameters)
|
|
321
|
+
return {"result": result, "tool": name, "success": True}
|
|
322
|
+
except Exception as e:
|
|
323
|
+
return {"error": str(e), "tool": name, "success": False}
|
|
324
|
+
|
|
325
|
+
async def _read_file(self, path: str) -> str:
|
|
326
|
+
# Resolve path relative to current working directory
|
|
327
|
+
p = Path(path)
|
|
328
|
+
if not p.is_absolute():
|
|
329
|
+
p = Path.cwd() / p
|
|
330
|
+
p = p.resolve()
|
|
331
|
+
if not p.exists():
|
|
332
|
+
return f"Error: File '{path}' not found"
|
|
333
|
+
if not p.is_file():
|
|
334
|
+
return f"Error: '{path}' is not a file"
|
|
335
|
+
return p.read_text(encoding='utf-8', errors='ignore')
|
|
336
|
+
|
|
337
|
+
def _resolve_in_workspace(self, path: str) -> Path:
|
|
338
|
+
"""Resolve `path` against workspace_root (or cwd if none was given),
|
|
339
|
+
raising if it escapes the workspace boundary. Only enforced when
|
|
340
|
+
`self.workspace_root` is set -- see __init__'s docstring on why
|
|
341
|
+
legacy no-arg callers get today's unrestricted behaviour instead."""
|
|
342
|
+
base = Path(self.workspace_root) if self.workspace_root else Path.cwd()
|
|
343
|
+
p = Path(path)
|
|
344
|
+
if not p.is_absolute():
|
|
345
|
+
p = base / p
|
|
346
|
+
resolved = p.resolve()
|
|
347
|
+
if self.workspace_root:
|
|
348
|
+
root = base.resolve()
|
|
349
|
+
if resolved != root and root not in resolved.parents:
|
|
350
|
+
raise PermissionError(f"'{path}' resolves outside the workspace root ({root})")
|
|
351
|
+
return resolved
|
|
352
|
+
|
|
353
|
+
async def _write_file(self, path: str, content: str) -> str:
|
|
354
|
+
p = self._resolve_in_workspace(path)
|
|
355
|
+
original_content = p.read_text(encoding="utf-8", errors="ignore") if p.is_file() else None
|
|
356
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
357
|
+
p.write_text(content, encoding='utf-8')
|
|
358
|
+
if not p.exists():
|
|
359
|
+
return f"❌ Failed to write to '{path}'"
|
|
360
|
+
if self.session_id is not None:
|
|
361
|
+
from .safety import record_mutation
|
|
362
|
+
record_mutation(
|
|
363
|
+
self.session_id, path=str(p), operation="create" if original_content is None else "update",
|
|
364
|
+
original_content=original_content, new_content=content,
|
|
365
|
+
)
|
|
366
|
+
return f"✅ Successfully wrote {len(content)} bytes to '{path}'"
|
|
367
|
+
|
|
368
|
+
async def _edit_file(self, path: str, old_string: str, new_string: str) -> str:
|
|
369
|
+
p = self._resolve_in_workspace(path)
|
|
370
|
+
if not p.is_file():
|
|
371
|
+
return f"❌ Error: File '{path}' not found"
|
|
372
|
+
original_content = p.read_text(encoding="utf-8", errors="ignore")
|
|
373
|
+
occurrences = original_content.count(old_string)
|
|
374
|
+
if occurrences == 0:
|
|
375
|
+
return f"❌ Error: old_string not found in '{path}' -- no changes made"
|
|
376
|
+
if occurrences > 1:
|
|
377
|
+
return (
|
|
378
|
+
f"❌ Error: old_string matches {occurrences} times in '{path}' -- it must be unique. "
|
|
379
|
+
"Include more surrounding context to disambiguate."
|
|
380
|
+
)
|
|
381
|
+
new_content = original_content.replace(old_string, new_string, 1)
|
|
382
|
+
p.write_text(new_content, encoding="utf-8")
|
|
383
|
+
if self.session_id is not None:
|
|
384
|
+
from .safety import record_mutation
|
|
385
|
+
record_mutation(
|
|
386
|
+
self.session_id, path=str(p), operation="update",
|
|
387
|
+
original_content=original_content, new_content=new_content,
|
|
388
|
+
)
|
|
389
|
+
return f"✅ Edited '{path}'"
|
|
390
|
+
|
|
391
|
+
async def _list_directory(self, path: str = ".") -> List[Dict[str, Any]]:
|
|
392
|
+
p = Path(path)
|
|
393
|
+
if not p.exists():
|
|
394
|
+
return [{"error": f"Directory '{path}' not found"}]
|
|
395
|
+
if not p.is_dir():
|
|
396
|
+
return [{"error": f"'{path}' is not a directory"}]
|
|
397
|
+
|
|
398
|
+
results = []
|
|
399
|
+
for item in p.iterdir():
|
|
400
|
+
results.append({
|
|
401
|
+
"name": item.name,
|
|
402
|
+
"path": str(item),
|
|
403
|
+
"is_file": item.is_file(),
|
|
404
|
+
"is_dir": item.is_dir(),
|
|
405
|
+
"size": item.stat().st_size if item.exists() else 0,
|
|
406
|
+
"modified": item.stat().st_mtime if item.exists() else 0,
|
|
407
|
+
})
|
|
408
|
+
return sorted(results, key=lambda x: x['name'])
|
|
409
|
+
|
|
410
|
+
async def _search_code(self, query: str, path: str = ".", file_pattern: str = None) -> List[Dict[str, Any]]:
|
|
411
|
+
try:
|
|
412
|
+
cmd = ['rg', '--json', '--line-number', '--no-heading', query, path]
|
|
413
|
+
if file_pattern:
|
|
414
|
+
cmd.extend(['--glob', file_pattern])
|
|
415
|
+
|
|
416
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
417
|
+
matches = []
|
|
418
|
+
|
|
419
|
+
for line in result.stdout.split('\n'):
|
|
420
|
+
if line.strip():
|
|
421
|
+
try:
|
|
422
|
+
data = json.loads(line)
|
|
423
|
+
if data.get('type') == 'match':
|
|
424
|
+
matches.append({
|
|
425
|
+
'file': data['data']['path']['text'],
|
|
426
|
+
'line': data['data']['line_number'],
|
|
427
|
+
'content': data['data']['lines']['text'].strip(),
|
|
428
|
+
})
|
|
429
|
+
except json.JSONDecodeError:
|
|
430
|
+
continue
|
|
431
|
+
|
|
432
|
+
return matches
|
|
433
|
+
except subprocess.TimeoutExpired:
|
|
434
|
+
return [{"error": "Search timed out"}]
|
|
435
|
+
except FileNotFoundError:
|
|
436
|
+
return [{"error": "ripgrep (rg) not installed"}]
|
|
437
|
+
|
|
438
|
+
async def _execute_command(self, command: str, timeout: int = 60) -> Dict[str, Any]:
|
|
439
|
+
try:
|
|
440
|
+
proc = await asyncio.create_subprocess_shell(
|
|
441
|
+
command,
|
|
442
|
+
stdout=asyncio.subprocess.PIPE,
|
|
443
|
+
stderr=asyncio.subprocess.PIPE
|
|
444
|
+
)
|
|
445
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
446
|
+
return {
|
|
447
|
+
"stdout": stdout.decode('utf-8', errors='ignore'),
|
|
448
|
+
"stderr": stderr.decode('utf-8', errors='ignore'),
|
|
449
|
+
"return_code": proc.returncode,
|
|
450
|
+
"success": proc.returncode == 0
|
|
451
|
+
}
|
|
452
|
+
except asyncio.TimeoutError:
|
|
453
|
+
return {"error": f"Command timed out after {timeout} seconds", "success": False}
|
|
454
|
+
except Exception as e:
|
|
455
|
+
return {"error": str(e), "success": False}
|
|
456
|
+
|
|
457
|
+
async def _get_git_info(self, path: str = ".") -> Dict[str, Any]:
|
|
458
|
+
p = Path(path)
|
|
459
|
+
if not p.exists():
|
|
460
|
+
return {"error": f"Path '{path}' not found"}
|
|
461
|
+
|
|
462
|
+
info = {"path": str(p)}
|
|
463
|
+
|
|
464
|
+
# Check if it's a git repo
|
|
465
|
+
git_dir = p / ".git"
|
|
466
|
+
if not git_dir.exists():
|
|
467
|
+
info["is_git_repo"] = False
|
|
468
|
+
return info
|
|
469
|
+
|
|
470
|
+
info["is_git_repo"] = True
|
|
471
|
+
|
|
472
|
+
try:
|
|
473
|
+
# Get current branch
|
|
474
|
+
result = subprocess.run(
|
|
475
|
+
['git', '-C', str(p), 'rev-parse', '--abbrev-ref', 'HEAD'],
|
|
476
|
+
capture_output=True, text=True
|
|
477
|
+
)
|
|
478
|
+
if result.returncode == 0:
|
|
479
|
+
info["branch"] = result.stdout.strip()
|
|
480
|
+
|
|
481
|
+
# Get remote URL
|
|
482
|
+
result = subprocess.run(
|
|
483
|
+
['git', '-C', str(p), 'config', '--get', 'remote.origin.url'],
|
|
484
|
+
capture_output=True, text=True
|
|
485
|
+
)
|
|
486
|
+
if result.returncode == 0:
|
|
487
|
+
info["remote_url"] = result.stdout.strip()
|
|
488
|
+
|
|
489
|
+
# Get latest commit
|
|
490
|
+
result = subprocess.run(
|
|
491
|
+
['git', '-C', str(p), 'log', '-1', '--format=%H%n%s%n%an%n%ae%n%ad'],
|
|
492
|
+
capture_output=True, text=True
|
|
493
|
+
)
|
|
494
|
+
if result.returncode == 0:
|
|
495
|
+
lines = result.stdout.split('\n')
|
|
496
|
+
if len(lines) >= 5:
|
|
497
|
+
info["latest_commit"] = {
|
|
498
|
+
"hash": lines[0],
|
|
499
|
+
"message": lines[1],
|
|
500
|
+
"author": lines[2],
|
|
501
|
+
"email": lines[3],
|
|
502
|
+
"date": lines[4],
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
# Get status
|
|
506
|
+
result = subprocess.run(
|
|
507
|
+
['git', '-C', str(p), 'status', '--porcelain'],
|
|
508
|
+
capture_output=True, text=True
|
|
509
|
+
)
|
|
510
|
+
info["has_changes"] = bool(result.stdout.strip())
|
|
511
|
+
info["changed_files"] = len([l for l in result.stdout.split('\n') if l.strip()])
|
|
512
|
+
|
|
513
|
+
except Exception as e:
|
|
514
|
+
info["git_error"] = str(e)
|
|
515
|
+
|
|
516
|
+
return info
|
|
517
|
+
|
|
518
|
+
async def _browser(self, **parameters: Any) -> Dict[str, Any]:
|
|
519
|
+
"""Public-web browser facade for ``tamfis-code tools call``.
|
|
520
|
+
|
|
521
|
+
The agentic Remote path injects trusted task context separately and
|
|
522
|
+
can therefore test loopback development servers. This direct facade
|
|
523
|
+
intentionally receives no trusted fields, so BrowserTool keeps its
|
|
524
|
+
public-only SSRF boundary.
|
|
525
|
+
"""
|
|
526
|
+
browser_tool = get_browser_tool_class()
|
|
527
|
+
if browser_tool is None:
|
|
528
|
+
raise RuntimeError("BrowserTool unavailable outside a monorepo checkout")
|
|
529
|
+
result = await browser_tool().execute_async(**parameters)
|
|
530
|
+
if not result.get("success"):
|
|
531
|
+
raise RuntimeError(str(result.get("error") or "Browser action failed"))
|
|
532
|
+
return result
|
|
533
|
+
|
|
534
|
+
# Convenience function for CLI use
|
|
535
|
+
async def call_tool(name: str, **kwargs):
|
|
536
|
+
"""Call a tool with given parameters"""
|
|
537
|
+
server = MCPServer()
|
|
538
|
+
return await server.call_tool(name, kwargs)
|
tamfis_code/metrics.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Streaming metrics and performance monitoring"""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
import threading
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Optional, Dict, Any
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class StreamMetrics:
|
|
11
|
+
"""Real-time streaming metrics"""
|
|
12
|
+
tokens_used: int = 0
|
|
13
|
+
tokens_per_second: float = 0.0
|
|
14
|
+
estimated_cost: float = 0.0
|
|
15
|
+
model_name: str = "default"
|
|
16
|
+
context_used: int = 0
|
|
17
|
+
response_time_ms: float = 0.0
|
|
18
|
+
start_time: datetime = field(default_factory=datetime.now)
|
|
19
|
+
last_update: datetime = field(default_factory=datetime.now)
|
|
20
|
+
|
|
21
|
+
# Per-command tracking
|
|
22
|
+
command_tokens: Dict[str, int] = field(default_factory=dict)
|
|
23
|
+
command_times: Dict[str, float] = field(default_factory=dict)
|
|
24
|
+
|
|
25
|
+
def update(self, tokens: int, elapsed_ms: float) -> None:
|
|
26
|
+
"""Update metrics with new data"""
|
|
27
|
+
self.tokens_used += tokens
|
|
28
|
+
self.response_time_ms = elapsed_ms
|
|
29
|
+
self.last_update = datetime.now()
|
|
30
|
+
|
|
31
|
+
delta = (self.last_update - self.start_time).total_seconds()
|
|
32
|
+
if delta > 0:
|
|
33
|
+
self.tokens_per_second = self.tokens_used / delta
|
|
34
|
+
|
|
35
|
+
def estimate_cost(self, model: str) -> float:
|
|
36
|
+
"""Estimate cost based on model pricing"""
|
|
37
|
+
rates = {
|
|
38
|
+
'claude-3': 0.015 / 1000,
|
|
39
|
+
'claude-3.5': 0.020 / 1000,
|
|
40
|
+
'gpt-4': 0.030 / 1000,
|
|
41
|
+
'gpt-3.5': 0.002 / 1000,
|
|
42
|
+
'deepseek': 0.001 / 1000,
|
|
43
|
+
'default': 0.010 / 1000,
|
|
44
|
+
}
|
|
45
|
+
rate = rates.get(model, rates['default'])
|
|
46
|
+
return self.tokens_used * rate
|
|
47
|
+
|
|
48
|
+
def format_display(self) -> str:
|
|
49
|
+
"""Format metrics for display"""
|
|
50
|
+
elapsed = (datetime.now() - self.start_time).total_seconds()
|
|
51
|
+
cost = self.estimate_cost(self.model_name)
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
f"📊 {self.tokens_used} tokens | "
|
|
55
|
+
f"{self.tokens_per_second:.1f} t/s | "
|
|
56
|
+
f"${cost:.4f} | "
|
|
57
|
+
f"{elapsed:.1f}s"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
class MetricsTracker:
|
|
61
|
+
"""Track metrics across a session"""
|
|
62
|
+
|
|
63
|
+
def __init__(self):
|
|
64
|
+
self.metrics = StreamMetrics()
|
|
65
|
+
self._active = False
|
|
66
|
+
self._timer: Optional[threading.Timer] = None
|
|
67
|
+
self._display_callback = None
|
|
68
|
+
|
|
69
|
+
def start(self, display_callback=None):
|
|
70
|
+
"""Start tracking metrics"""
|
|
71
|
+
self._active = True
|
|
72
|
+
self.metrics.start_time = datetime.now()
|
|
73
|
+
self._display_callback = display_callback
|
|
74
|
+
self._update_loop()
|
|
75
|
+
|
|
76
|
+
def stop(self):
|
|
77
|
+
"""Stop tracking"""
|
|
78
|
+
self._active = False
|
|
79
|
+
if self._timer:
|
|
80
|
+
self._timer.cancel()
|
|
81
|
+
|
|
82
|
+
def _update_loop(self):
|
|
83
|
+
"""Periodic update loop"""
|
|
84
|
+
if not self._active:
|
|
85
|
+
return
|
|
86
|
+
if self._display_callback:
|
|
87
|
+
self._display_callback(self.metrics.format_display())
|
|
88
|
+
self._timer = threading.Timer(0.5, self._update_loop)
|
|
89
|
+
self._timer.daemon = True
|
|
90
|
+
self._timer.start()
|
|
91
|
+
|
|
92
|
+
def record(self, tokens: int, elapsed_ms: float, model: str = "default"):
|
|
93
|
+
"""Record a response"""
|
|
94
|
+
self.metrics.update(tokens, elapsed_ms)
|
|
95
|
+
self.metrics.model_name = model
|
|
96
|
+
|
|
97
|
+
def get_summary(self) -> Dict[str, Any]:
|
|
98
|
+
"""Get metrics summary"""
|
|
99
|
+
return {
|
|
100
|
+
"tokens_used": self.metrics.tokens_used,
|
|
101
|
+
"tokens_per_second": self.metrics.tokens_per_second,
|
|
102
|
+
"estimated_cost": self.metrics.estimated_cost,
|
|
103
|
+
"model": self.metrics.model_name,
|
|
104
|
+
"elapsed_seconds": (datetime.now() - self.metrics.start_time).total_seconds(),
|
|
105
|
+
}
|