msapling-cli 0.1.2__tar.gz → 0.1.4__tar.gz
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.
- {msapling_cli-0.1.2 → msapling_cli-0.1.4}/PKG-INFO +4 -1
- {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli/__init__.py +1 -1
- {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli/agent.py +318 -14
- {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli/api.py +188 -11
- {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli/completer.py +5 -2
- {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli/config.py +5 -2
- {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli/main.py +1 -1
- {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli/shell.py +418 -90
- {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli.egg-info/PKG-INFO +4 -1
- {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli.egg-info/requires.txt +4 -0
- {msapling_cli-0.1.2 → msapling_cli-0.1.4}/pyproject.toml +3 -2
- {msapling_cli-0.1.2 → msapling_cli-0.1.4}/tests/test_agent.py +8 -1
- msapling_cli-0.1.4/tests/test_api.py +159 -0
- {msapling_cli-0.1.2 → msapling_cli-0.1.4}/tests/test_config.py +1 -1
- msapling_cli-0.1.2/tests/test_api.py +0 -89
- {msapling_cli-0.1.2 → msapling_cli-0.1.4}/LICENSE +0 -0
- {msapling_cli-0.1.2 → msapling_cli-0.1.4}/README.md +0 -0
- {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli/local.py +0 -0
- {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli/mcp/__init__.py +0 -0
- {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli/mcp/server.py +0 -0
- {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli/memory.py +0 -0
- {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli/session.py +0 -0
- {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli/storage.py +0 -0
- {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli/tier.py +0 -0
- {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli/tui.py +0 -0
- {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli/worker_pool.py +0 -0
- {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli.egg-info/SOURCES.txt +0 -0
- {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli.egg-info/dependency_links.txt +0 -0
- {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli.egg-info/entry_points.txt +0 -0
- {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli.egg-info/top_level.txt +0 -0
- {msapling_cli-0.1.2 → msapling_cli-0.1.4}/setup.cfg +0 -0
- {msapling_cli-0.1.2 → msapling_cli-0.1.4}/tests/test_local.py +0 -0
- {msapling_cli-0.1.2 → msapling_cli-0.1.4}/tests/test_mcp.py +0 -0
- {msapling_cli-0.1.2 → msapling_cli-0.1.4}/tests/test_memory.py +0 -0
- {msapling_cli-0.1.2 → msapling_cli-0.1.4}/tests/test_session.py +0 -0
- {msapling_cli-0.1.2 → msapling_cli-0.1.4}/tests/test_shell.py +0 -0
- {msapling_cli-0.1.2 → msapling_cli-0.1.4}/tests/test_storage.py +0 -0
- {msapling_cli-0.1.2 → msapling_cli-0.1.4}/tests/test_tier.py +0 -0
- {msapling_cli-0.1.2 → msapling_cli-0.1.4}/tests/test_tui.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: msapling-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: MSapling CLI - Multi-chat AI development environment in your terminal
|
|
5
5
|
Author: MSapling Team
|
|
6
6
|
License-Expression: MIT
|
|
@@ -31,9 +31,12 @@ Provides-Extra: keyring
|
|
|
31
31
|
Requires-Dist: keyring>=24.0; extra == "keyring"
|
|
32
32
|
Provides-Extra: tui
|
|
33
33
|
Requires-Dist: textual>=0.40.0; extra == "tui"
|
|
34
|
+
Provides-Extra: pdf
|
|
35
|
+
Requires-Dist: pdfplumber>=0.10.0; extra == "pdf"
|
|
34
36
|
Provides-Extra: all
|
|
35
37
|
Requires-Dist: keyring>=24.0; extra == "all"
|
|
36
38
|
Requires-Dist: textual>=0.40.0; extra == "all"
|
|
39
|
+
Requires-Dist: pdfplumber>=0.10.0; extra == "all"
|
|
37
40
|
Provides-Extra: dev
|
|
38
41
|
Requires-Dist: build>=1.2.0; extra == "dev"
|
|
39
42
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""MSapling CLI - Multi-chat AI development environment in your terminal."""
|
|
2
|
-
__version__ = "0.1.
|
|
2
|
+
__version__ = "0.1.4"
|
|
@@ -56,7 +56,10 @@ _permission_mode: str = "ask"
|
|
|
56
56
|
_permissions: Dict[str, bool] = {}
|
|
57
57
|
|
|
58
58
|
# Read-only tools that never require approval in any mode
|
|
59
|
-
_READ_ONLY_TOOLS = frozenset({
|
|
59
|
+
_READ_ONLY_TOOLS = frozenset({
|
|
60
|
+
"read_file", "glob_files", "grep_search", "web_search", "web_fetch",
|
|
61
|
+
"cost_check", "list_directory", "ask_user", "todo_list",
|
|
62
|
+
})
|
|
60
63
|
|
|
61
64
|
# Write tools blocked in readonly/plan mode
|
|
62
65
|
_WRITE_TOOLS = frozenset({"write_file", "edit_file", "run_command"})
|
|
@@ -131,6 +134,31 @@ AGENT_TOOLS_SCHEMA = [
|
|
|
131
134
|
"description": "Search the web for current information. Uses MSapling's backend search. Use when the user asks about recent events, documentation, or anything that might be outdated in your training data.",
|
|
132
135
|
"parameters": {"query": "string (search query)"},
|
|
133
136
|
},
|
|
137
|
+
{
|
|
138
|
+
"name": "web_fetch",
|
|
139
|
+
"description": "Fetch the content of a specific URL. Returns the page text (HTML stripped). Use for reading documentation, API references, or specific web pages.",
|
|
140
|
+
"parameters": {"url": "string (full URL to fetch)"},
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
"name": "list_directory",
|
|
144
|
+
"description": "List files and directories at a path with sizes and types. More detailed than glob_files.",
|
|
145
|
+
"parameters": {"path": "string (relative directory path, default '.')"},
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
"name": "ask_user",
|
|
149
|
+
"description": "Ask the user a clarifying question when you need more information to proceed. Use sparingly — only when truly ambiguous.",
|
|
150
|
+
"parameters": {"question": "string (the question to ask)"},
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"name": "todo_list",
|
|
154
|
+
"description": "Manage a task checklist for the current session. Use to plan multi-step work and track progress.",
|
|
155
|
+
"parameters": {"action": "string ('add', 'done', 'list', 'clear')", "item": "string (task description, for 'add' and 'done')"},
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
"name": "search_replace_all",
|
|
159
|
+
"description": "Replace ALL occurrences of a string in a file (not just the first). Use when renaming a variable or updating all imports.",
|
|
160
|
+
"parameters": {"path": "string (relative file path)", "old_string": "string (text to find)", "new_string": "string (replacement)"},
|
|
161
|
+
},
|
|
134
162
|
{
|
|
135
163
|
"name": "cost_check",
|
|
136
164
|
"description": "Check the user's remaining fuel credits and session cost before expensive operations. Use this before large file writes or multi-step tasks.",
|
|
@@ -157,17 +185,22 @@ To use a tool, respond with a JSON block in this format:
|
|
|
157
185
|
```
|
|
158
186
|
|
|
159
187
|
Available tools:
|
|
160
|
-
- read_file: Read a file. Args: path, offset (optional), limit (optional)
|
|
188
|
+
- read_file: Read a file (supports text, PDF, notebooks). Args: path, offset (optional), limit (optional)
|
|
161
189
|
- write_file: Create/overwrite a file. Args: path, content
|
|
162
|
-
- edit_file: Replace
|
|
190
|
+
- edit_file: Replace first occurrence of a string. Args: path, old_string, new_string
|
|
191
|
+
- search_replace_all: Replace ALL occurrences in a file. Args: path, old_string, new_string
|
|
163
192
|
- run_command: Execute a shell command. Args: command
|
|
193
|
+
- list_directory: List files with sizes and types. Args: path (default ".")
|
|
164
194
|
- glob_files: Find files by pattern. Args: pattern
|
|
165
195
|
- grep_search: Search file contents. Args: pattern, path (optional)
|
|
166
196
|
- web_search: Search the web via MSapling backend. Args: query
|
|
167
|
-
-
|
|
168
|
-
-
|
|
197
|
+
- web_fetch: Fetch content of a specific URL. Args: url
|
|
198
|
+
- ask_user: Ask the user a clarifying question. Args: question
|
|
199
|
+
- todo_list: Manage task checklist. Args: action (add/done/list/clear), item
|
|
200
|
+
- cost_check: Check remaining fuel credits. No args.
|
|
201
|
+
- model_switch: Switch model mid-task. Args: model
|
|
169
202
|
|
|
170
|
-
|
|
203
|
+
Tips: Use cost_check before expensive tasks. Use model_switch for cheaper models on grunt work. Use search_replace_all for renaming variables across a file.
|
|
171
204
|
|
|
172
205
|
You can use multiple tools in one response. After tool results, continue reasoning.
|
|
173
206
|
When you're done (no more tools needed), just respond with your final message.
|
|
@@ -356,17 +389,103 @@ def _web_search(query: str) -> str:
|
|
|
356
389
|
return f"Web search error: {e}"
|
|
357
390
|
|
|
358
391
|
|
|
392
|
+
# ─── Command Sandbox ─────────────────────────────────────────────────
|
|
393
|
+
# Block dangerous command patterns. Not OS-level but catches common mistakes.
|
|
394
|
+
|
|
395
|
+
_BLOCKED_COMMAND_PATTERNS = [
|
|
396
|
+
r"\brm\s+(-[rRf]+\s+)?/", # rm -rf /
|
|
397
|
+
r"\brm\s+-[rRf]*\s+~", # rm -rf ~
|
|
398
|
+
r"\brm\s+-[rRf]*\s+\.\.", # rm -rf ..
|
|
399
|
+
r"\bsudo\b", # sudo anything
|
|
400
|
+
r"\bchmod\s+777\b", # chmod 777
|
|
401
|
+
r"\bchmod\s+-R\b", # chmod -R (recursive)
|
|
402
|
+
r"\bmkfs\b", # format filesystem
|
|
403
|
+
r"\bdd\s+if=", # dd disk write
|
|
404
|
+
r"\b:()\s*\{", # fork bomb
|
|
405
|
+
r"\bcurl\b.*\|\s*bash", # curl | bash
|
|
406
|
+
r"\bwget\b.*\|\s*bash", # wget | bash
|
|
407
|
+
r"\bcurl\b.*\|\s*sh", # curl | sh
|
|
408
|
+
r"\bwget\b.*\|\s*sh", # wget | sh
|
|
409
|
+
r">\s*/dev/sd", # write to disk device
|
|
410
|
+
r"\bshutdown\b", # shutdown
|
|
411
|
+
r"\breboot\b", # reboot
|
|
412
|
+
r"\bkill\s+-9\s+1\b", # kill init
|
|
413
|
+
r"\biptables\b", # firewall rules
|
|
414
|
+
r"\bufw\b", # firewall
|
|
415
|
+
]
|
|
416
|
+
|
|
417
|
+
_sandbox_enabled: bool = True
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _check_command_sandbox(command: str) -> Optional[str]:
|
|
421
|
+
"""Check if a command is blocked by the sandbox. Returns error message or None."""
|
|
422
|
+
if not _sandbox_enabled:
|
|
423
|
+
return None
|
|
424
|
+
cmd_lower = command.lower().strip()
|
|
425
|
+
for pattern in _BLOCKED_COMMAND_PATTERNS:
|
|
426
|
+
if re.search(pattern, cmd_lower):
|
|
427
|
+
return f"Blocked by sandbox: '{command[:60]}' matches dangerous pattern. Use /sandbox off to disable."
|
|
428
|
+
return None
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def set_sandbox(enabled: bool) -> bool:
|
|
432
|
+
global _sandbox_enabled
|
|
433
|
+
_sandbox_enabled = enabled
|
|
434
|
+
return _sandbox_enabled
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# Strong references to background tasks so they don't get GC'd mid-flight.
|
|
438
|
+
# See: https://docs.python.org/3/library/asyncio-task.html#creating-tasks
|
|
439
|
+
_background_tasks: set = set()
|
|
440
|
+
|
|
441
|
+
# Agent todo list (managed via todo_list tool)
|
|
442
|
+
_agent_todos: List[Dict[str, Any]] = []
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _mdrive_auto_push(project_root: str, rel_path: str, content: str, project_name: str = "", agent_id: str = "cli-agent"):
|
|
446
|
+
"""Background push to MDrive after local file write. Best-effort, non-blocking."""
|
|
447
|
+
if not project_name:
|
|
448
|
+
return
|
|
449
|
+
try:
|
|
450
|
+
import asyncio as _aio
|
|
451
|
+
from .api import MSaplingClient
|
|
452
|
+
|
|
453
|
+
async def _push():
|
|
454
|
+
client = MSaplingClient()
|
|
455
|
+
try:
|
|
456
|
+
await client.mdrive_write(
|
|
457
|
+
project_name, rel_path, content,
|
|
458
|
+
agent_id=agent_id,
|
|
459
|
+
change_summary="Auto-pushed by agent tool",
|
|
460
|
+
)
|
|
461
|
+
finally:
|
|
462
|
+
await client.close()
|
|
463
|
+
|
|
464
|
+
try:
|
|
465
|
+
loop = _aio.get_running_loop()
|
|
466
|
+
task = loop.create_task(_push())
|
|
467
|
+
# Hold strong reference until task completes
|
|
468
|
+
_background_tasks.add(task)
|
|
469
|
+
task.add_done_callback(_background_tasks.discard)
|
|
470
|
+
except RuntimeError:
|
|
471
|
+
_aio.run(_push())
|
|
472
|
+
except Exception:
|
|
473
|
+
pass # Best-effort, never block the agent
|
|
474
|
+
|
|
475
|
+
|
|
359
476
|
def _execute_tool(
|
|
360
477
|
tool_name: str,
|
|
361
478
|
args: Dict[str, Any],
|
|
362
479
|
project_root: str,
|
|
363
480
|
hooks_mgr: Any = None,
|
|
364
481
|
model: str = "",
|
|
482
|
+
project_name: str = "",
|
|
365
483
|
) -> str:
|
|
366
484
|
"""Execute a single tool call locally. Returns result as string.
|
|
367
485
|
|
|
368
486
|
When *hooks_mgr* is provided (a HooksManager), the function fires
|
|
369
487
|
*on_file_write* hooks after a successful write_file or edit_file.
|
|
488
|
+
When *project_name* is set, auto-pushes written files to MDrive.
|
|
370
489
|
"""
|
|
371
490
|
|
|
372
491
|
# --- Approval gate for mutating tools ---
|
|
@@ -382,7 +501,47 @@ def _execute_tool(
|
|
|
382
501
|
if not fpath.is_file():
|
|
383
502
|
return f"Error: file not found: {path}"
|
|
384
503
|
try:
|
|
385
|
-
|
|
504
|
+
ext = fpath.suffix.lower()
|
|
505
|
+
|
|
506
|
+
# PDF reading
|
|
507
|
+
if ext == ".pdf":
|
|
508
|
+
try:
|
|
509
|
+
import pdfplumber
|
|
510
|
+
text_parts = []
|
|
511
|
+
with pdfplumber.open(fpath) as pdf:
|
|
512
|
+
for i, page in enumerate(pdf.pages[:30]):
|
|
513
|
+
page_text = page.extract_text() or ""
|
|
514
|
+
if page_text.strip():
|
|
515
|
+
text_parts.append(f"--- Page {i+1} ---\n{page_text}")
|
|
516
|
+
content = "\n\n".join(text_parts) if text_parts else "(no extractable text)"
|
|
517
|
+
except ImportError:
|
|
518
|
+
return "Error: PDF reading requires pdfplumber. Install: pip install pdfplumber"
|
|
519
|
+
|
|
520
|
+
# Jupyter notebook reading
|
|
521
|
+
elif ext == ".ipynb":
|
|
522
|
+
try:
|
|
523
|
+
nb = json.loads(fpath.read_text(encoding="utf-8"))
|
|
524
|
+
cells = nb.get("cells", [])
|
|
525
|
+
parts = []
|
|
526
|
+
for i, cell in enumerate(cells):
|
|
527
|
+
cell_type = cell.get("cell_type", "code")
|
|
528
|
+
source = "".join(cell.get("source", []))
|
|
529
|
+
if cell_type == "markdown":
|
|
530
|
+
parts.append(f"[Markdown cell {i+1}]\n{source}")
|
|
531
|
+
else:
|
|
532
|
+
parts.append(f"[Code cell {i+1}]\n```\n{source}\n```")
|
|
533
|
+
outputs = cell.get("outputs", [])
|
|
534
|
+
for out in outputs[:3]:
|
|
535
|
+
if "text" in out:
|
|
536
|
+
parts.append("Output: " + "".join(out["text"])[:500])
|
|
537
|
+
content = "\n\n".join(parts)
|
|
538
|
+
except Exception as e:
|
|
539
|
+
return f"Error parsing notebook: {e}"
|
|
540
|
+
|
|
541
|
+
# Standard text file
|
|
542
|
+
else:
|
|
543
|
+
content = fpath.read_text(encoding="utf-8", errors="ignore")
|
|
544
|
+
|
|
386
545
|
offset = int(args.get("offset", 0))
|
|
387
546
|
limit = int(args.get("limit", 0))
|
|
388
547
|
if offset or limit:
|
|
@@ -411,6 +570,8 @@ def _execute_tool(
|
|
|
411
570
|
project=project_root, model=model,
|
|
412
571
|
file=str(fpath), tool="write_file",
|
|
413
572
|
)
|
|
573
|
+
# Auto-push to MDrive (best-effort, non-blocking)
|
|
574
|
+
_mdrive_auto_push(project_root, path, content, project_name)
|
|
414
575
|
return f"Wrote {len(content)} bytes to {path}"
|
|
415
576
|
except Exception as e:
|
|
416
577
|
return f"Error writing {path}: {e}"
|
|
@@ -431,7 +592,9 @@ def _execute_tool(
|
|
|
431
592
|
# Backup before editing (like Claude Code's file-history)
|
|
432
593
|
try:
|
|
433
594
|
from .storage import backup_file
|
|
434
|
-
|
|
595
|
+
import hashlib
|
|
596
|
+
session_key = hashlib.md5(project_root.encode()).hexdigest()[:12]
|
|
597
|
+
backup_file(path, content, session_key)
|
|
435
598
|
except Exception:
|
|
436
599
|
pass
|
|
437
600
|
new_content = content.replace(old_string, new_string, 1)
|
|
@@ -442,6 +605,8 @@ def _execute_tool(
|
|
|
442
605
|
project=project_root, model=model,
|
|
443
606
|
file=str(fpath), tool="edit_file",
|
|
444
607
|
)
|
|
608
|
+
# Auto-push to MDrive (best-effort, non-blocking)
|
|
609
|
+
_mdrive_auto_push(project_root, path, new_content, project_name)
|
|
445
610
|
return f"Edited {path}: replaced {len(old_string)} chars with {len(new_string)} chars"
|
|
446
611
|
except Exception as e:
|
|
447
612
|
return f"Error editing {path}: {e}"
|
|
@@ -450,10 +615,14 @@ def _execute_tool(
|
|
|
450
615
|
command = args.get("command", "")
|
|
451
616
|
if not command:
|
|
452
617
|
return "Error: no command provided"
|
|
618
|
+
# Sandbox check: block dangerous patterns
|
|
619
|
+
blocked = _check_command_sandbox(command)
|
|
620
|
+
if blocked:
|
|
621
|
+
return blocked
|
|
453
622
|
try:
|
|
454
623
|
result = subprocess.run(
|
|
455
624
|
command, shell=True, cwd=project_root,
|
|
456
|
-
capture_output=True, text=True, timeout=
|
|
625
|
+
capture_output=True, text=True, timeout=60,
|
|
457
626
|
)
|
|
458
627
|
output = ""
|
|
459
628
|
if result.stdout:
|
|
@@ -463,7 +632,7 @@ def _execute_tool(
|
|
|
463
632
|
output += f"\n(exit code: {result.returncode})"
|
|
464
633
|
return output or "(no output)"
|
|
465
634
|
except subprocess.TimeoutExpired:
|
|
466
|
-
return "Error: command timed out (
|
|
635
|
+
return "Error: command timed out (60s limit)"
|
|
467
636
|
except Exception as e:
|
|
468
637
|
return f"Error running command: {e}"
|
|
469
638
|
|
|
@@ -511,11 +680,138 @@ def _execute_tool(
|
|
|
511
680
|
return "Error: no search query provided"
|
|
512
681
|
return _web_search(query)
|
|
513
682
|
|
|
514
|
-
elif tool_name == "
|
|
683
|
+
elif tool_name == "web_fetch":
|
|
684
|
+
url = args.get("url", "")
|
|
685
|
+
if not url:
|
|
686
|
+
return "Error: no URL provided"
|
|
515
687
|
try:
|
|
688
|
+
# Try backend first, fall back to direct fetch
|
|
516
689
|
from .config import get_settings, get_token
|
|
690
|
+
import httpx as _httpx
|
|
691
|
+
settings = get_settings()
|
|
692
|
+
api_url = settings.api_url.rstrip("/")
|
|
693
|
+
token = get_token()
|
|
694
|
+
cookies = {"msaplingauth": token} if token else {}
|
|
695
|
+
try:
|
|
696
|
+
resp = _httpx.post(
|
|
697
|
+
f"{api_url}/api/tools/web-research",
|
|
698
|
+
json={"url": url}, cookies=cookies, timeout=15.0,
|
|
699
|
+
)
|
|
700
|
+
if resp.status_code == 200:
|
|
701
|
+
content = resp.json().get("content", resp.json().get("text", ""))
|
|
702
|
+
if content and len(content) > 20:
|
|
703
|
+
return content[:8000]
|
|
704
|
+
except Exception:
|
|
705
|
+
pass
|
|
706
|
+
# Direct fetch fallback
|
|
707
|
+
resp = _httpx.get(url, timeout=15.0, follow_redirects=True,
|
|
708
|
+
headers={"User-Agent": "MSapling-CLI/0.2"})
|
|
709
|
+
text = resp.text
|
|
710
|
+
# Strip HTML tags for readability
|
|
711
|
+
text = re.sub(r"<script[^>]*>.*?</script>", "", text, flags=re.DOTALL)
|
|
712
|
+
text = re.sub(r"<style[^>]*>.*?</style>", "", text, flags=re.DOTALL)
|
|
713
|
+
text = re.sub(r"<[^>]+>", " ", text)
|
|
714
|
+
text = re.sub(r"\s+", " ", text).strip()
|
|
715
|
+
return text[:8000] if text else "No content found"
|
|
716
|
+
except Exception as e:
|
|
717
|
+
return f"Error fetching URL: {e}"
|
|
718
|
+
|
|
719
|
+
elif tool_name == "list_directory":
|
|
720
|
+
dir_path = args.get("path", ".")
|
|
721
|
+
fpath = _safe_path(project_root, dir_path)
|
|
722
|
+
if not fpath:
|
|
723
|
+
return f"Error: path '{dir_path}' is outside project root"
|
|
724
|
+
if not fpath.is_dir():
|
|
725
|
+
return f"Error: not a directory: {dir_path}"
|
|
726
|
+
try:
|
|
727
|
+
skip = {".git", "node_modules", "__pycache__", "venv", ".venv", "dist", "build"}
|
|
728
|
+
entries = []
|
|
729
|
+
for item in sorted(fpath.iterdir()):
|
|
730
|
+
name = item.name
|
|
731
|
+
if name in skip:
|
|
732
|
+
continue
|
|
733
|
+
if item.is_dir():
|
|
734
|
+
entries.append(f" {name}/")
|
|
735
|
+
else:
|
|
736
|
+
size = item.stat().st_size
|
|
737
|
+
size_str = f"{size / 1024:.1f}KB" if size > 1024 else f"{size}B"
|
|
738
|
+
entries.append(f" {name} ({size_str})")
|
|
739
|
+
if len(entries) >= 80:
|
|
740
|
+
entries.append(f" ... (truncated)")
|
|
741
|
+
break
|
|
742
|
+
return "\n".join(entries) if entries else "(empty directory)"
|
|
743
|
+
except Exception as e:
|
|
744
|
+
return f"Error: {e}"
|
|
745
|
+
|
|
746
|
+
elif tool_name == "ask_user":
|
|
747
|
+
question = args.get("question", "")
|
|
748
|
+
if not question:
|
|
749
|
+
return "Error: no question provided"
|
|
750
|
+
try:
|
|
751
|
+
console.print(f"\n[bold yellow]Agent asks:[/bold yellow] {question}")
|
|
752
|
+
answer = console.input("[bold green]Your answer:[/bold green] ")
|
|
753
|
+
return answer.strip() or "(no answer)"
|
|
754
|
+
except (KeyboardInterrupt, EOFError):
|
|
755
|
+
return "(user declined to answer)"
|
|
756
|
+
|
|
757
|
+
elif tool_name == "todo_list":
|
|
758
|
+
action = args.get("action", "list").lower()
|
|
759
|
+
item = args.get("item", "")
|
|
760
|
+
# Module-level todo storage
|
|
761
|
+
global _agent_todos
|
|
762
|
+
if action == "add" and item:
|
|
763
|
+
_agent_todos.append({"text": item, "done": False})
|
|
764
|
+
return f"Added: {item} ({len(_agent_todos)} total)"
|
|
765
|
+
elif action == "done" and item:
|
|
766
|
+
for t in _agent_todos:
|
|
767
|
+
if item.lower() in t["text"].lower() and not t["done"]:
|
|
768
|
+
t["done"] = True
|
|
769
|
+
return f"Completed: {t['text']}"
|
|
770
|
+
return f"Not found: {item}"
|
|
771
|
+
elif action == "clear":
|
|
772
|
+
_agent_todos.clear()
|
|
773
|
+
return "Todo list cleared"
|
|
774
|
+
else:
|
|
775
|
+
if not _agent_todos:
|
|
776
|
+
return "Todo list is empty"
|
|
777
|
+
lines = []
|
|
778
|
+
for i, t in enumerate(_agent_todos, 1):
|
|
779
|
+
mark = "x" if t["done"] else " "
|
|
780
|
+
lines.append(f" [{mark}] {i}. {t['text']}")
|
|
781
|
+
return "\n".join(lines)
|
|
782
|
+
|
|
783
|
+
elif tool_name == "search_replace_all":
|
|
784
|
+
path = args.get("path", "")
|
|
785
|
+
old_string = args.get("old_string", "")
|
|
786
|
+
new_string = args.get("new_string", "")
|
|
787
|
+
fpath = _safe_path(project_root, path)
|
|
788
|
+
if not fpath:
|
|
789
|
+
return f"Error: path '{path}' is outside project root"
|
|
790
|
+
if not fpath.is_file():
|
|
791
|
+
return f"Error: file not found: {path}"
|
|
792
|
+
try:
|
|
793
|
+
content = fpath.read_text(encoding="utf-8", errors="ignore")
|
|
794
|
+
count = content.count(old_string)
|
|
795
|
+
if count == 0:
|
|
796
|
+
return f"Error: string not found in {path}"
|
|
797
|
+
try:
|
|
798
|
+
from .storage import backup_file
|
|
799
|
+
import hashlib
|
|
800
|
+
session_key = hashlib.md5(project_root.encode()).hexdigest()[:12]
|
|
801
|
+
backup_file(path, content, session_key)
|
|
802
|
+
except Exception:
|
|
803
|
+
pass
|
|
804
|
+
new_content = content.replace(old_string, new_string)
|
|
805
|
+
fpath.write_text(new_content, encoding="utf-8")
|
|
806
|
+
_mdrive_auto_push(project_root, path, new_content, project_name)
|
|
807
|
+
return f"Replaced {count} occurrence(s) in {path}"
|
|
808
|
+
except Exception as e:
|
|
809
|
+
return f"Error: {e}"
|
|
810
|
+
|
|
811
|
+
elif tool_name == "cost_check":
|
|
812
|
+
try:
|
|
517
813
|
from .api import MSaplingClient
|
|
518
|
-
import
|
|
814
|
+
import concurrent.futures
|
|
519
815
|
|
|
520
816
|
async def _check():
|
|
521
817
|
client = MSaplingClient()
|
|
@@ -531,7 +827,11 @@ def _execute_tool(
|
|
|
531
827
|
finally:
|
|
532
828
|
await client.close()
|
|
533
829
|
|
|
534
|
-
|
|
830
|
+
# _execute_tool is sync but called from within a running event loop.
|
|
831
|
+
# Use a thread to avoid "event loop already running" RuntimeError.
|
|
832
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
|
833
|
+
result = pool.submit(asyncio.run, _check()).result(timeout=10)
|
|
834
|
+
return result
|
|
535
835
|
except Exception as e:
|
|
536
836
|
return f"Cost check error: {e}"
|
|
537
837
|
|
|
@@ -566,6 +866,8 @@ async def run_agent_loop(
|
|
|
566
866
|
on_text: Any = None,
|
|
567
867
|
on_usage: Any = None,
|
|
568
868
|
hooks_mgr: Any = None,
|
|
869
|
+
project_name: str = "",
|
|
870
|
+
chat_id: str = "",
|
|
569
871
|
) -> str:
|
|
570
872
|
"""Run the agentic tool-use loop.
|
|
571
873
|
|
|
@@ -600,9 +902,10 @@ async def run_agent_loop(
|
|
|
600
902
|
# Get LLM response
|
|
601
903
|
parts = []
|
|
602
904
|
async for chunk in client.stream_chat(
|
|
603
|
-
chat_id=str(uuid.uuid4())[:12],
|
|
905
|
+
chat_id=chat_id or str(uuid.uuid4())[:12],
|
|
604
906
|
prompt=prompt if step == 0 else "",
|
|
605
907
|
model=model,
|
|
908
|
+
project_name=project_name,
|
|
606
909
|
history=messages[-30:], # Keep recent context
|
|
607
910
|
):
|
|
608
911
|
content = chunk.get("content", "")
|
|
@@ -648,6 +951,7 @@ async def run_agent_loop(
|
|
|
648
951
|
result = _execute_tool(
|
|
649
952
|
tool_name, tool_args, project_root,
|
|
650
953
|
hooks_mgr=hooks_mgr, model=model,
|
|
954
|
+
project_name=project_name,
|
|
651
955
|
)
|
|
652
956
|
|
|
653
957
|
# on_after_tool hook
|