aria-code 4.1.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.
- agents/__init__.py +32 -0
- agents/base.py +190 -0
- agents/deep/__init__.py +37 -0
- agents/deep/calibration_loop.py +144 -0
- agents/deep/critic.py +125 -0
- agents/deep/deepen.py +193 -0
- agents/deep/models.py +149 -0
- agents/deep/pipeline.py +164 -0
- agents/deep/quant_fusion.py +192 -0
- agents/deep/themes.py +95 -0
- agents/deep/tiers.py +106 -0
- agents/financial/__init__.py +10 -0
- agents/financial/catalyst.py +279 -0
- agents/financial/debate.py +145 -0
- agents/financial/earnings.py +303 -0
- agents/financial/fundamental.py +159 -0
- agents/financial/macro.py +99 -0
- agents/financial/news.py +207 -0
- agents/financial/risk.py +132 -0
- agents/financial/sector.py +279 -0
- agents/financial/synthesis.py +274 -0
- agents/financial/technical.py +258 -0
- agents/portfolio_agent.py +333 -0
- agents/realty/__init__.py +62 -0
- agents/realty/asset_diagnosis.py +150 -0
- agents/realty/business_match.py +165 -0
- agents/realty/cashflow_verify.py +208 -0
- agents/realty/contract_rules.py +209 -0
- agents/realty/energy_anomaly.py +188 -0
- agents/realty/exit_settlement.py +207 -0
- agents/realty/fulfillment_risk.py +205 -0
- agents/realty/ops_optimize.py +159 -0
- agents/realty/revenue_share.py +214 -0
- agents/registry.py +144 -0
- agents/sports/__init__.py +0 -0
- agents/sports/football_agent.py +169 -0
- agents/team.py +289 -0
- aliyun_data_client.py +660 -0
- apps/README.md +12 -0
- apps/__init__.py +2 -0
- apps/channels/README.md +15 -0
- apps/cli/README.md +13 -0
- apps/cli/__init__.py +2 -0
- apps/cli/bootstrap.py +99 -0
- apps/cli/codegen_paths.py +29 -0
- apps/cli/commands/__init__.py +16 -0
- apps/cli/commands/analysis_cmds.py +288 -0
- apps/cli/commands/backtest_cmds.py +1887 -0
- apps/cli/commands/broker_cmds.py +1154 -0
- apps/cli/commands/business_workflow_cmds.py +289 -0
- apps/cli/commands/catalog.py +84 -0
- apps/cli/commands/data_cmds.py +405 -0
- apps/cli/commands/diagnostic_cmds.py +179 -0
- apps/cli/commands/diagnostic_ops_cmds.py +696 -0
- apps/cli/commands/finance_render.py +12 -0
- apps/cli/commands/market.py +399 -0
- apps/cli/commands/market_cmds.py +1276 -0
- apps/cli/commands/market_context.py +425 -0
- apps/cli/commands/market_render.py +7 -0
- apps/cli/commands/model_cmds.py +1579 -0
- apps/cli/commands/ops_cmds.py +668 -0
- apps/cli/commands/portfolio_cmds.py +962 -0
- apps/cli/commands/report.py +377 -0
- apps/cli/commands/scaffold_templates.py +617 -0
- apps/cli/commands/session_cmds.py +179 -0
- apps/cli/commands/session_ux_cmds.py +280 -0
- apps/cli/commands/team.py +588 -0
- apps/cli/commands/team_render.py +8 -0
- apps/cli/commands/ui_cmds.py +358 -0
- apps/cli/commands/workflow_cmds.py +279 -0
- apps/cli/commands/workspace_cmds.py +1414 -0
- apps/cli/config_paths.py +70 -0
- apps/cli/config_store.py +61 -0
- apps/cli/deterministic.py +122 -0
- apps/cli/direct.py +48 -0
- apps/cli/github_app_auth.py +135 -0
- apps/cli/handlers/__init__.py +11 -0
- apps/cli/handlers/broker_handlers.py +122 -0
- apps/cli/handlers/chart_handlers.py +1309 -0
- apps/cli/handlers/market_handlers.py +2509 -0
- apps/cli/handlers/realty_handlers.py +114 -0
- apps/cli/handlers/strategy_advice.py +82 -0
- apps/cli/hooks.py +180 -0
- apps/cli/i18n.py +284 -0
- apps/cli/intent.py +136 -0
- apps/cli/intent_router.py +217 -0
- apps/cli/lifecycle_hooks.py +48 -0
- apps/cli/main.py +29 -0
- apps/cli/market_metadata.py +135 -0
- apps/cli/market_universe.py +265 -0
- apps/cli/message_processing.py +257 -0
- apps/cli/plan_mode.py +139 -0
- apps/cli/plotly_html.py +15 -0
- apps/cli/prediction_feedback.py +202 -0
- apps/cli/preflight.py +497 -0
- apps/cli/project_aria.py +60 -0
- apps/cli/prompts/__init__.py +0 -0
- apps/cli/prompts/coding.py +658 -0
- apps/cli/prompts/system_prompts.py +531 -0
- apps/cli/prompts/ui.py +434 -0
- apps/cli/providers/__init__.py +1 -0
- apps/cli/providers/base.py +271 -0
- apps/cli/providers/chat_routing.py +80 -0
- apps/cli/providers/llm/__init__.py +1 -0
- apps/cli/providers/llm/ollama_stream.py +1170 -0
- apps/cli/providers/llm/sse_stream.py +216 -0
- apps/cli/providers/runtime_bridge.py +185 -0
- apps/cli/runtime_consumer.py +489 -0
- apps/cli/session_export.py +87 -0
- apps/cli/session_jsonl.py +207 -0
- apps/cli/session_store.py +112 -0
- apps/cli/todo_tracker.py +190 -0
- apps/cli/tools/__init__.py +40 -0
- apps/cli/tools/context.py +46 -0
- apps/cli/tools/file_tools.py +112 -0
- apps/cli/tools/market_tools.py +549 -0
- apps/cli/tools/notebook_tools.py +111 -0
- apps/cli/tools/system_tools.py +669 -0
- apps/cli/tools/write_tools.py +715 -0
- apps/cli/tradingview_bridge.py +434 -0
- apps/cli/update_check.py +152 -0
- apps/cli/utils/__init__.py +0 -0
- apps/cli/utils/market_detect.py +1578 -0
- apps/daemon/README.md +14 -0
- apps/vscode/README.md +115 -0
- apps/vscode/package.json +70 -0
- aria_cli.py +11636 -0
- aria_code-4.1.3.dist-info/METADATA +952 -0
- aria_code-4.1.3.dist-info/RECORD +284 -0
- aria_code-4.1.3.dist-info/WHEEL +5 -0
- aria_code-4.1.3.dist-info/entry_points.txt +2 -0
- aria_code-4.1.3.dist-info/licenses/LICENSE +121 -0
- aria_code-4.1.3.dist-info/top_level.txt +50 -0
- aria_daemon.py +1295 -0
- aria_feishu_bot.py +1359 -0
- aria_relay_client.py +182 -0
- aria_relay_server.py +405 -0
- aria_telegram_bot.py +202 -0
- ariarc.py +328 -0
- artifacts.py +491 -0
- backtest_report.py +472 -0
- brokers/__init__.py +72 -0
- brokers/base.py +207 -0
- brokers/capabilities.py +264 -0
- brokers/cn/__init__.py +10 -0
- brokers/cn/easytrader_broker.py +193 -0
- brokers/cn/futu_broker.py +194 -0
- brokers/cn/longbridge_broker.py +190 -0
- brokers/cn/tiger_broker.py +196 -0
- brokers/cn/xtquant_broker.py +175 -0
- brokers/config.py +364 -0
- brokers/intl/__init__.py +5 -0
- brokers/intl/alpaca_broker.py +183 -0
- brokers/intl/ibkr_broker.py +215 -0
- brokers/intl/webull_broker.py +156 -0
- brokers/paper_broker.py +259 -0
- brokers/planning.py +296 -0
- brokers/registry.py +181 -0
- brokers/trading.py +237 -0
- change_store.py +127 -0
- command_safety.py +19 -0
- computer_use_tools.py +504 -0
- dashboard_generator.py +578 -0
- data_analysis_tools.py +808 -0
- data_cleaner.py +483 -0
- data_service.py +481 -0
- datasources/__init__.py +23 -0
- datasources/base.py +166 -0
- datasources/router.py +221 -0
- datasources/sources/__init__.py +15 -0
- datasources/sources/akshare_source.py +269 -0
- datasources/sources/alpha_vantage_source.py +202 -0
- datasources/sources/edgar_source.py +218 -0
- datasources/sources/finnhub_source.py +197 -0
- datasources/sources/fred_source.py +219 -0
- datasources/sources/tushare_source.py +141 -0
- datasources/sources/web_scraper_source.py +278 -0
- datasources/sources/world_bank_source.py +205 -0
- datasources/sources/yfinance_source.py +152 -0
- demo_player.py +204 -0
- doctor.py +508 -0
- file_analysis_tools.py +734 -0
- finance_formulas.py +389 -0
- football_data_client.py +1670 -0
- intent_classifier.py +358 -0
- local_finance_tools.py +3221 -0
- local_llm_provider.py +552 -0
- macro_tools.py +368 -0
- market_data_client.py +1899 -0
- mcp_client.py +506 -0
- memory_manager.py +245 -0
- model_capability.py +416 -0
- notification_tools.py +248 -0
- packages/__init__.py +23 -0
- packages/aria_agents/__init__.py +5 -0
- packages/aria_agents/manifest.py +69 -0
- packages/aria_core/__init__.py +34 -0
- packages/aria_core/architecture.py +192 -0
- packages/aria_core/export.py +124 -0
- packages/aria_core/manifest.py +65 -0
- packages/aria_infra/__init__.py +15 -0
- packages/aria_infra/arthera.py +52 -0
- packages/aria_infra/doctor.py +246 -0
- packages/aria_infra/product.py +37 -0
- packages/aria_mcp/__init__.py +25 -0
- packages/aria_mcp/bridge.py +38 -0
- packages/aria_mcp/config.py +97 -0
- packages/aria_mcp/tools.py +61 -0
- packages/aria_sdk/__init__.py +19 -0
- packages/aria_sdk/client.py +396 -0
- packages/aria_sdk/providers.py +70 -0
- packages/aria_sdk/streaming.py +73 -0
- packages/aria_sdk/types.py +86 -0
- packages/aria_services/__init__.py +55 -0
- packages/aria_services/context.py +258 -0
- packages/aria_services/data.py +11 -0
- packages/aria_services/provider_health.py +189 -0
- packages/aria_services/registry.py +213 -0
- packages/aria_services/usage.py +138 -0
- packages/aria_skills/__init__.py +5 -0
- packages/aria_skills/registry.py +59 -0
- packages/aria_tools/__init__.py +5 -0
- packages/aria_tools/registry.py +128 -0
- packages/quant_engine/__init__.py +6 -0
- packages/quant_engine/sports/__init__.py +72 -0
- packages/quant_engine/sports/calibrator.py +353 -0
- packages/quant_engine/sports/dixon_coles.py +234 -0
- packages/quant_engine/sports/elo.py +299 -0
- packages/quant_engine/sports/form.py +188 -0
- packages/quant_engine/sports/h2h.py +195 -0
- packages/quant_engine/sports/ml_model.py +354 -0
- packages/quant_engine/sports/predictor.py +311 -0
- packages/quant_engine/sports/tracker.py +664 -0
- packages/quant_engine/stochastic/__init__.py +27 -0
- packages/quant_engine/stochastic/gbm_enhanced.py +195 -0
- packages/quant_engine/stochastic/ito_calculus.py +477 -0
- packages/quant_engine/stochastic/kelly_criterion.py +181 -0
- packages/quant_engine/stochastic/monte_carlo_advanced.py +95 -0
- packages/quant_engine/stochastic/options_pricing.py +573 -0
- packages/quant_engine/stochastic/stochastic_processes.py +90 -0
- plan_utils.py +194 -0
- plugin_loader.py +328 -0
- portfolio_ledger.py +262 -0
- privacy/__init__.py +5 -0
- privacy/feedback.py +123 -0
- project_tools.py +525 -0
- providers/__init__.py +30 -0
- providers/llm/__init__.py +19 -0
- providers/llm/anthropic.py +184 -0
- providers/llm/base.py +139 -0
- providers/llm/ollama.py +128 -0
- providers/llm/openai_compat.py +282 -0
- providers/llm/registry.py +358 -0
- realty_data_tools.py +659 -0
- report_generator.py +1314 -0
- runtime/__init__.py +103 -0
- runtime/agent_loop.py +1183 -0
- runtime/approval.py +51 -0
- runtime/events.py +102 -0
- runtime/gateway.py +128 -0
- runtime/lsp.py +346 -0
- runtime/subagent.py +258 -0
- runtime/tool_executor.py +104 -0
- runtime/tool_policy.py +106 -0
- safety/__init__.py +21 -0
- safety/permissions.py +275 -0
- setup_wizard.py +653 -0
- strategy_vault.py +420 -0
- ui/__init__.py +100 -0
- ui/banner.py +310 -0
- ui/completer.py +391 -0
- ui/console.py +271 -0
- ui/image_render.py +243 -0
- ui/input_box.py +376 -0
- ui/picker.py +195 -0
- ui/render/__init__.py +11 -0
- ui/render/finance.py +1480 -0
- ui/render/market.py +225 -0
- ui/render/output.py +681 -0
- ui/render/team.py +346 -0
- ui/robot.py +235 -0
- workspace/__init__.py +6 -0
- workspace/files.py +170 -0
- workspace/verify.py +113 -0
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
"""Write/edit tool implementations extracted from aria_cli.py.
|
|
2
|
+
|
|
3
|
+
These functions depend on aria_cli globals (GLOBAL_CHANGE_STORE, console, etc.).
|
|
4
|
+
They use lazy runtime imports to avoid circular imports at load time — aria_cli
|
|
5
|
+
is already fully initialised by the time any tool executes.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import pathlib
|
|
10
|
+
import re as _re
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ── Lazy access to aria_cli singletons ───────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
def _ac():
|
|
16
|
+
"""Return the aria_cli module (already loaded, never reimported from scratch)."""
|
|
17
|
+
import aria_cli
|
|
18
|
+
return aria_cli
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _ui():
|
|
22
|
+
"""Return (console, HAS_RICH) from the live aria_cli namespace."""
|
|
23
|
+
m = _ac()
|
|
24
|
+
return getattr(m, "console", None), getattr(m, "HAS_RICH", False)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _change_store():
|
|
28
|
+
return _ac().GLOBAL_CHANGE_STORE
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _verify_python_syntax(path: "pathlib.Path", content: str) -> "str | None":
|
|
32
|
+
"""Compile-check a Python file after a write/edit.
|
|
33
|
+
|
|
34
|
+
Returns a short LLM-actionable error string if the file no longer parses,
|
|
35
|
+
or None if it is fine / not a Python file. This mirrors Claude Code's
|
|
36
|
+
edit→verify discipline so syntax breakage is caught immediately instead of
|
|
37
|
+
at the next run.
|
|
38
|
+
"""
|
|
39
|
+
if path.suffix != ".py":
|
|
40
|
+
return None
|
|
41
|
+
try:
|
|
42
|
+
compile(content, str(path), "exec")
|
|
43
|
+
return None
|
|
44
|
+
except SyntaxError as exc:
|
|
45
|
+
line = exc.lineno or "?"
|
|
46
|
+
msg = exc.msg or "syntax error"
|
|
47
|
+
# Show the offending line for context
|
|
48
|
+
ctx = ""
|
|
49
|
+
try:
|
|
50
|
+
lines = content.splitlines()
|
|
51
|
+
if isinstance(exc.lineno, int) and 1 <= exc.lineno <= len(lines):
|
|
52
|
+
ctx = f"\n → 第 {line} 行: {lines[exc.lineno - 1].strip()[:120]}"
|
|
53
|
+
except Exception:
|
|
54
|
+
pass
|
|
55
|
+
return (
|
|
56
|
+
f"⚠ 语法检查失败 (SyntaxError: {msg} @ line {line}){ctx}\n"
|
|
57
|
+
f"改动已写入,但文件无法编译。请用 read_file 查看该行附近,再用 edit_file 修复语法。"
|
|
58
|
+
)
|
|
59
|
+
except Exception:
|
|
60
|
+
# Non-syntax compile issues (e.g. null bytes) — don't block, just skip
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _write_policy():
|
|
65
|
+
return _ac()._ACTIVE_WRITE_POLICY
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _lsp_autocheck(path: "pathlib.Path") -> "tuple[str | None, list]":
|
|
69
|
+
"""Optionally run language-server diagnostics after a write/edit.
|
|
70
|
+
|
|
71
|
+
Gated on config flag `lsp_autocheck` (default off) so the common edit path
|
|
72
|
+
pays zero cost. When on, this surfaces real diagnostics — undefined names,
|
|
73
|
+
type errors, unused imports — that the syntax compile-check can't catch, the
|
|
74
|
+
way Claude Code injects LSP feedback after edits. Returns (summary, diags);
|
|
75
|
+
summary is None when there's nothing to report. Never raises.
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
if not _ac()._ACTIVE_LSP_AUTOCHECK[0]:
|
|
79
|
+
return None, []
|
|
80
|
+
except Exception:
|
|
81
|
+
return None, []
|
|
82
|
+
try:
|
|
83
|
+
from runtime.lsp import server_for, get_diagnostics
|
|
84
|
+
if not server_for(path):
|
|
85
|
+
return None, []
|
|
86
|
+
diags = get_diagnostics(path, timeout=6.0)
|
|
87
|
+
except Exception:
|
|
88
|
+
return None, []
|
|
89
|
+
if not diags:
|
|
90
|
+
return None, []
|
|
91
|
+
errors = sum(1 for d in diags if d["severity"] == "error")
|
|
92
|
+
warnings = sum(1 for d in diags if d["severity"] == "warning")
|
|
93
|
+
if errors == 0 and warnings == 0:
|
|
94
|
+
return None, diags
|
|
95
|
+
head = diags[0]
|
|
96
|
+
summary = (
|
|
97
|
+
f"LSP 诊断: {errors} error / {warnings} warning. "
|
|
98
|
+
f"首条 — 第 {head['line']} 行: {head['message'][:120]}"
|
|
99
|
+
)
|
|
100
|
+
return summary, diags
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _print_lsp_diags(diags: list, console, has_rich: bool, limit: int = 6) -> None:
|
|
104
|
+
"""Print a compact diagnostics list after an auto-checked edit."""
|
|
105
|
+
if not (diags and console):
|
|
106
|
+
return
|
|
107
|
+
_color = {"error": "red", "warning": "yellow", "info": "cyan", "hint": "dim"}
|
|
108
|
+
for d in diags[:limit]:
|
|
109
|
+
if has_rich:
|
|
110
|
+
c = _color.get(d["severity"], "white")
|
|
111
|
+
console.print(f" [{c}]●[/{c}] [dim]{d['line']}:{d['col']}[/dim] "
|
|
112
|
+
f"{d['message'][:100]}")
|
|
113
|
+
else:
|
|
114
|
+
print(f" {d['line']}:{d['col']} {d['severity']}: {d['message'][:100]}")
|
|
115
|
+
if len(diags) > limit:
|
|
116
|
+
extra = len(diags) - limit
|
|
117
|
+
console.print(f" [dim]… +{extra} more[/dim]") if has_rich else print(f" ... +{extra} more")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _is_safe(p: pathlib.Path) -> bool:
|
|
121
|
+
return _ac()._is_safe_path(p)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _config_dir() -> str:
|
|
125
|
+
return str(_ac().CONFIG_DIR)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _sessions_dir() -> str:
|
|
129
|
+
return str(_ac().SESSIONS_DIR)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _ChangeConflictError():
|
|
133
|
+
return _ac().ChangeConflictError
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
def _strip_markdown_fences(content: str) -> str:
|
|
139
|
+
"""Strip markdown code fences that LLMs sometimes wrap around file content."""
|
|
140
|
+
stripped = content.strip()
|
|
141
|
+
if stripped.startswith("```"):
|
|
142
|
+
first_nl = stripped.find("\n")
|
|
143
|
+
if first_nl >= 0:
|
|
144
|
+
stripped = stripped[first_nl + 1:]
|
|
145
|
+
else:
|
|
146
|
+
return content
|
|
147
|
+
if stripped.rstrip().endswith("```"):
|
|
148
|
+
stripped = stripped.rstrip()[:-3].rstrip()
|
|
149
|
+
if stripped != content.strip():
|
|
150
|
+
return stripped + "\n"
|
|
151
|
+
return content
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _auto_fix_python(content: str, path: str) -> str:
|
|
155
|
+
"""Auto-inject missing imports and validate syntax for Python files."""
|
|
156
|
+
if not path.endswith(".py"):
|
|
157
|
+
return content
|
|
158
|
+
|
|
159
|
+
lines = content.split("\n")
|
|
160
|
+
imports_present: set[str] = set()
|
|
161
|
+
first_non_comment = 0
|
|
162
|
+
for i, line in enumerate(lines):
|
|
163
|
+
s = line.strip()
|
|
164
|
+
if s and not s.startswith("#") and not s.startswith('"""') and not s.startswith("'''"):
|
|
165
|
+
first_non_comment = i
|
|
166
|
+
break
|
|
167
|
+
|
|
168
|
+
for line in lines:
|
|
169
|
+
s = line.strip()
|
|
170
|
+
if s.startswith("import "):
|
|
171
|
+
parts = s.split()
|
|
172
|
+
if len(parts) >= 2:
|
|
173
|
+
imports_present.add(parts[1].split(".")[0].split(",")[0])
|
|
174
|
+
elif s.startswith("from "):
|
|
175
|
+
parts = s.split()
|
|
176
|
+
if len(parts) >= 2:
|
|
177
|
+
imports_present.add(parts[1].split(".")[0])
|
|
178
|
+
|
|
179
|
+
code = content
|
|
180
|
+
needed: list[str] = []
|
|
181
|
+
|
|
182
|
+
if ("os.path" in code or "os.expanduser" in code or "os.getcwd" in code
|
|
183
|
+
or "os.makedirs" in code) and "os" not in imports_present:
|
|
184
|
+
needed.append("import os")
|
|
185
|
+
if ("sys." in code or "sys.exit" in code) and "sys" not in imports_present:
|
|
186
|
+
needed.append("import sys")
|
|
187
|
+
if "np." in code and "numpy" not in imports_present and "np" not in imports_present:
|
|
188
|
+
needed.append("import numpy as np")
|
|
189
|
+
if "pd." in code and "pandas" not in imports_present and "pd" not in imports_present:
|
|
190
|
+
needed.append("import pandas as pd")
|
|
191
|
+
if "yf." in code and "yfinance" not in imports_present and "yf" not in imports_present:
|
|
192
|
+
needed.append("import yfinance as yf")
|
|
193
|
+
|
|
194
|
+
has_plt = "plt." in code
|
|
195
|
+
has_matplotlib_use = "matplotlib.use" in code
|
|
196
|
+
if has_plt and "matplotlib" not in imports_present:
|
|
197
|
+
needed.append("import matplotlib; matplotlib.use('Agg')")
|
|
198
|
+
needed.append("import matplotlib.pyplot as plt")
|
|
199
|
+
elif has_plt and not has_matplotlib_use:
|
|
200
|
+
for i, line in enumerate(lines):
|
|
201
|
+
if "import matplotlib.pyplot" in line and "matplotlib.use" not in "\n".join(lines[:i]):
|
|
202
|
+
lines.insert(i, "import matplotlib; matplotlib.use('Agg')")
|
|
203
|
+
content = "\n".join(lines)
|
|
204
|
+
break
|
|
205
|
+
|
|
206
|
+
if "mpf." in code and "mplfinance" not in imports_present and "mpf" not in imports_present:
|
|
207
|
+
needed.append("import mplfinance as mpf")
|
|
208
|
+
if "re." in code and "re" not in imports_present:
|
|
209
|
+
needed.append("import re")
|
|
210
|
+
if "json." in code and "json" not in imports_present:
|
|
211
|
+
needed.append("import json")
|
|
212
|
+
if "datetime" in code and "datetime" not in imports_present:
|
|
213
|
+
needed.append("from datetime import datetime, timedelta")
|
|
214
|
+
if (_re.search(r'\bta\.(?:sma|ema|rsi|macd|bbands|stoch|atr|adx|obv|vwap)\b', code)
|
|
215
|
+
or "pandas_ta" in code) and "ta" not in imports_present and "pandas_ta" not in imports_present:
|
|
216
|
+
needed.append("import pandas_ta as ta")
|
|
217
|
+
if (_re.search(r'\bgo\.(?:Figure|Candlestick|Scatter|Bar|Heatmap|Layout|Table)', code)
|
|
218
|
+
or "px." in code or "plotly" in code) and "plotly" not in imports_present:
|
|
219
|
+
if "go.Figure" in code or "go.Candlestick" in code:
|
|
220
|
+
needed.append("import plotly.graph_objects as go")
|
|
221
|
+
if "px." in code:
|
|
222
|
+
needed.append("import plotly.express as px")
|
|
223
|
+
if "make_subplots" in code:
|
|
224
|
+
needed.append("from plotly.subplots import make_subplots")
|
|
225
|
+
if "scipy" in code and "scipy" not in imports_present:
|
|
226
|
+
needed.append("import scipy")
|
|
227
|
+
|
|
228
|
+
has_warnings_in_needed = any("warnings" in n for n in needed)
|
|
229
|
+
if ("yf." in code or "pd." in code) and "warnings" not in imports_present and not has_warnings_in_needed:
|
|
230
|
+
needed.insert(0, "import warnings; warnings.filterwarnings('ignore')")
|
|
231
|
+
elif "warnings" in code and "warnings" not in imports_present and not has_warnings_in_needed:
|
|
232
|
+
needed.append("import warnings")
|
|
233
|
+
|
|
234
|
+
if needed:
|
|
235
|
+
for imp in reversed(needed):
|
|
236
|
+
lines.insert(first_non_comment, imp)
|
|
237
|
+
content = "\n".join(lines)
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
import ast
|
|
241
|
+
ast.parse(content)
|
|
242
|
+
except SyntaxError as e:
|
|
243
|
+
console, has_rich = _ui()
|
|
244
|
+
if has_rich and console:
|
|
245
|
+
console.print(f" [dim]Warning: syntax issue at line {e.lineno}: {e.msg}[/dim]")
|
|
246
|
+
|
|
247
|
+
return content
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _write_policy_confirm(p: pathlib.Path, content: str, existed: bool) -> tuple:
|
|
251
|
+
"""Prompt user to confirm a write. Returns (approved: bool, final_path: Path)."""
|
|
252
|
+
import difflib
|
|
253
|
+
console, has_rich = _ui()
|
|
254
|
+
lines_new = content.count("\n") + 1
|
|
255
|
+
desktop = pathlib.Path.home() / "Desktop"
|
|
256
|
+
is_desktop = str(p).startswith(str(desktop))
|
|
257
|
+
|
|
258
|
+
if has_rich and console:
|
|
259
|
+
console.print()
|
|
260
|
+
if existed:
|
|
261
|
+
old_content = p.read_text(errors="replace")
|
|
262
|
+
diff = list(difflib.unified_diff(
|
|
263
|
+
old_content.splitlines(keepends=True),
|
|
264
|
+
content.splitlines(keepends=True),
|
|
265
|
+
fromfile=f"current/{p.name}",
|
|
266
|
+
tofile=f"new/{p.name}",
|
|
267
|
+
n=2,
|
|
268
|
+
))
|
|
269
|
+
added = sum(1 for l in diff if l.startswith("+") and not l.startswith("+++"))
|
|
270
|
+
removed = sum(1 for l in diff if l.startswith("-") and not l.startswith("---"))
|
|
271
|
+
console.print(f" [yellow]⚠ Overwrite[/yellow] [bold]{p}[/bold]")
|
|
272
|
+
console.print(f" [dim] +{added} lines -{removed} lines ({lines_new} total)[/dim]")
|
|
273
|
+
for line in diff[:8]:
|
|
274
|
+
if line.startswith("+") and not line.startswith("+++"):
|
|
275
|
+
console.print(f" [green]{line.rstrip()}[/green]")
|
|
276
|
+
elif line.startswith("-") and not line.startswith("---"):
|
|
277
|
+
console.print(f" [red]{line.rstrip()}[/red]")
|
|
278
|
+
else:
|
|
279
|
+
loc = "[dim cyan](Desktop)[/dim cyan]" if is_desktop else "[yellow](outside Desktop)[/yellow]"
|
|
280
|
+
console.print(f" [cyan]New file[/cyan] {loc} [bold]{p}[/bold] ({lines_new} lines)")
|
|
281
|
+
console.print()
|
|
282
|
+
choice = console.input(" [bold]Write this file?[/bold] [dim]\\[y/n/r=redirect path][/dim] ").strip().lower()
|
|
283
|
+
else:
|
|
284
|
+
print()
|
|
285
|
+
print(f" {'Overwrite' if existed else 'New file'}: {p} ({lines_new} lines)")
|
|
286
|
+
choice = input(" Write this file? [y/n/r=redirect path] ").strip().lower()
|
|
287
|
+
|
|
288
|
+
if choice == "r":
|
|
289
|
+
if has_rich and console:
|
|
290
|
+
new_path_str = console.input(" [dim]Enter new path: [/dim]").strip()
|
|
291
|
+
else:
|
|
292
|
+
new_path_str = input(" Enter new path: ").strip()
|
|
293
|
+
if new_path_str:
|
|
294
|
+
new_p = pathlib.Path(new_path_str).expanduser().resolve()
|
|
295
|
+
if _is_safe(new_p):
|
|
296
|
+
return True, new_p
|
|
297
|
+
if has_rich and console:
|
|
298
|
+
console.print(f" [red]Path not allowed: {new_p}[/red]")
|
|
299
|
+
else:
|
|
300
|
+
print(f" Path not allowed: {new_p}")
|
|
301
|
+
return False, p
|
|
302
|
+
|
|
303
|
+
return choice in ("y", "yes", ""), p
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# ── Rich diff helper ─────────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
def _print_inline_diff(old_str: str, new_str: str, console, max_lines: int = 12) -> None:
|
|
309
|
+
"""Print a compact color-coded inline diff after an edit_file call."""
|
|
310
|
+
old_lines = old_str.splitlines()
|
|
311
|
+
new_lines = new_str.splitlines()
|
|
312
|
+
|
|
313
|
+
total_changed = max(len(old_lines), len(new_lines))
|
|
314
|
+
if total_changed > max_lines:
|
|
315
|
+
console.print(f" [dim] (diff too large to display inline — {total_changed} lines)[/dim]")
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
for line in old_lines:
|
|
319
|
+
display = line[:120]
|
|
320
|
+
console.print(f" [red dim]- {display}[/red dim]")
|
|
321
|
+
for line in new_lines:
|
|
322
|
+
display = line[:120]
|
|
323
|
+
console.print(f" [green dim]+ {display}[/green dim]")
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# ── Public tool functions ─────────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
def tool_write_file(params: dict) -> dict:
|
|
329
|
+
"""Write content to a file (create or overwrite)."""
|
|
330
|
+
path = params.get("path", "")
|
|
331
|
+
content = params.get("content", "")
|
|
332
|
+
skip_confirm = params.get("_skip_confirm", False)
|
|
333
|
+
stage_only = bool(params.get("stage_only", False))
|
|
334
|
+
|
|
335
|
+
if not path:
|
|
336
|
+
return {"success": False, "error": "Missing 'path' parameter"}
|
|
337
|
+
if not content:
|
|
338
|
+
return {"success": False, "error": "Missing 'content' parameter"}
|
|
339
|
+
|
|
340
|
+
content = _strip_markdown_fences(content)
|
|
341
|
+
stripped_check = content.strip()
|
|
342
|
+
|
|
343
|
+
if len(stripped_check) < 20:
|
|
344
|
+
return {"success": False,
|
|
345
|
+
"error": f"Content too short ({len(stripped_check)} chars). "
|
|
346
|
+
"You must write the COMPLETE script code, not a placeholder."}
|
|
347
|
+
|
|
348
|
+
if (stripped_check.startswith("<") and stripped_check.endswith(">")
|
|
349
|
+
and "\n" not in stripped_check and len(stripped_check) < 200
|
|
350
|
+
and not stripped_check.lower().startswith("<!doctype")
|
|
351
|
+
and not stripped_check.lower().startswith("<html")):
|
|
352
|
+
return {"success": False,
|
|
353
|
+
"error": f"Content appears to be a placeholder tag: '{stripped_check[:120]}'. "
|
|
354
|
+
"Write the complete code with imports, data fetching, computation, and output."}
|
|
355
|
+
|
|
356
|
+
# Reject trivial stub Python scripts — only print() with no real logic
|
|
357
|
+
if path.endswith(".py"):
|
|
358
|
+
import re as _re
|
|
359
|
+
_boilerplate = {
|
|
360
|
+
"#!/usr/bin/env python", "#!/usr/bin/python", "# -*- coding:",
|
|
361
|
+
'if __name__ == "__main__":', "if __name__ == '__main__':",
|
|
362
|
+
"def main():", "main()", "",
|
|
363
|
+
}
|
|
364
|
+
_work_lines = [
|
|
365
|
+
ln.strip() for ln in stripped_check.splitlines()
|
|
366
|
+
if ln.strip() and ln.strip() not in _boilerplate
|
|
367
|
+
and not ln.strip().startswith("#")
|
|
368
|
+
]
|
|
369
|
+
_all_print = _work_lines and all(
|
|
370
|
+
_re.match(r'^print\s*\(', ln) for ln in _work_lines
|
|
371
|
+
)
|
|
372
|
+
if _all_print and len(_work_lines) <= 3:
|
|
373
|
+
return {
|
|
374
|
+
"success": False,
|
|
375
|
+
"error": (
|
|
376
|
+
f"拒绝写入: '{pathlib.Path(path).name or 'file'}' 只包含 print() 语句,是无意义的占位脚本。"
|
|
377
|
+
" 请直接用文字输出结果,或者写包含真实逻辑的代码(网络请求、数据处理、计算等)。"
|
|
378
|
+
),
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
content = _auto_fix_python(content, path)
|
|
382
|
+
|
|
383
|
+
try:
|
|
384
|
+
raw_path = pathlib.Path(path).expanduser()
|
|
385
|
+
if not raw_path.is_absolute():
|
|
386
|
+
from artifacts import user_generated_dir
|
|
387
|
+
raw_path = user_generated_dir() / raw_path
|
|
388
|
+
p = raw_path.resolve()
|
|
389
|
+
if not _is_safe(p):
|
|
390
|
+
return {"success": False, "error": f"Access denied: path '{p}' is outside allowed directories"}
|
|
391
|
+
|
|
392
|
+
existed = p.exists()
|
|
393
|
+
desktop = pathlib.Path.home() / "Desktop"
|
|
394
|
+
import tempfile as _tf
|
|
395
|
+
from artifacts import user_output_root
|
|
396
|
+
user_root = user_output_root().resolve()
|
|
397
|
+
_auto_trusted_prefixes = (
|
|
398
|
+
str(desktop),
|
|
399
|
+
str(user_root),
|
|
400
|
+
str(pathlib.Path(_tf.gettempdir()).resolve()),
|
|
401
|
+
"/tmp", "/private/tmp", "/private/var/folders",
|
|
402
|
+
_config_dir(), _sessions_dir(),
|
|
403
|
+
)
|
|
404
|
+
is_auto_trusted = any(str(p).startswith(pfx) for pfx in _auto_trusted_prefixes)
|
|
405
|
+
policy = _write_policy()[0]
|
|
406
|
+
|
|
407
|
+
needs_confirm = (
|
|
408
|
+
not skip_confirm
|
|
409
|
+
and not is_auto_trusted
|
|
410
|
+
and (
|
|
411
|
+
policy == "always_confirm"
|
|
412
|
+
or policy in ("desktop_only", "confirm_outside")
|
|
413
|
+
or existed
|
|
414
|
+
)
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
if needs_confirm:
|
|
418
|
+
approved, p = _write_policy_confirm(p, content, existed)
|
|
419
|
+
if not approved:
|
|
420
|
+
return {"success": False, "error": "Write cancelled by user.",
|
|
421
|
+
"data": {"cancelled": True}}
|
|
422
|
+
|
|
423
|
+
console, has_rich = _ui()
|
|
424
|
+
store = _change_store()
|
|
425
|
+
change = store.stage(p, content, source="write_file")
|
|
426
|
+
lines = content.count("\n") + 1
|
|
427
|
+
action = "Updated" if existed else "Created"
|
|
428
|
+
|
|
429
|
+
if stage_only:
|
|
430
|
+
label = "Staged update" if existed else "Staged create"
|
|
431
|
+
if has_rich and console:
|
|
432
|
+
console.print(f" [dim]{label} {p} ({lines} lines, change {change.change_id})[/dim]")
|
|
433
|
+
else:
|
|
434
|
+
print(f" {label} {p} ({lines} lines, change {change.change_id})")
|
|
435
|
+
return {"success": True, "data": {
|
|
436
|
+
"path": str(p), "action": "staged", "lines": lines,
|
|
437
|
+
"change_id": change.change_id,
|
|
438
|
+
"before_hash": change.before_hash, "after_hash": change.after_hash,
|
|
439
|
+
"diff": change.diff, "staged": True, "applied": False,
|
|
440
|
+
}}
|
|
441
|
+
|
|
442
|
+
try:
|
|
443
|
+
applied = store.apply(change.change_id)
|
|
444
|
+
except _ChangeConflictError() as exc:
|
|
445
|
+
return {"success": False, "error": str(exc), "data": {"change_id": change.change_id}}
|
|
446
|
+
|
|
447
|
+
desktop = pathlib.Path.home() / "Desktop"
|
|
448
|
+
is_on_desktop = str(p).startswith(str(desktop))
|
|
449
|
+
|
|
450
|
+
import platform as _platform, subprocess as _sub
|
|
451
|
+
_sys_name = _platform.system()
|
|
452
|
+
if _sys_name == "Darwin":
|
|
453
|
+
_reveal_hint = f'open -R "{p}"'
|
|
454
|
+
elif _sys_name == "Windows":
|
|
455
|
+
_reveal_hint = f'explorer /select,"{p}"'
|
|
456
|
+
else:
|
|
457
|
+
_reveal_hint = f'xdg-open "{p.parent}"'
|
|
458
|
+
|
|
459
|
+
if has_rich and console:
|
|
460
|
+
console.print(f" [dim]{action} [bold]{p}[/bold] ({lines} lines)[/dim]")
|
|
461
|
+
if not is_on_desktop and p.suffix == ".py":
|
|
462
|
+
console.print(
|
|
463
|
+
f" [dim]提示: 文件保存在 [yellow]{p}[/yellow]\n"
|
|
464
|
+
f" 打开所在目录: [cyan]{_reveal_hint}[/cyan][/dim]"
|
|
465
|
+
)
|
|
466
|
+
else:
|
|
467
|
+
print(f" {action} {p} ({lines} lines)")
|
|
468
|
+
|
|
469
|
+
# Auto-reveal .py/.ipynb strategy files in file manager (non-blocking)
|
|
470
|
+
if p.suffix in (".py", ".ipynb"):
|
|
471
|
+
try:
|
|
472
|
+
if _sys_name == "Darwin":
|
|
473
|
+
_sub.Popen(["open", "-R", str(p)],
|
|
474
|
+
stdout=_sub.DEVNULL, stderr=_sub.DEVNULL)
|
|
475
|
+
elif _sys_name == "Windows":
|
|
476
|
+
_sub.Popen(["explorer", f"/select,{str(p)}"],
|
|
477
|
+
stdout=_sub.DEVNULL, stderr=_sub.DEVNULL)
|
|
478
|
+
except Exception:
|
|
479
|
+
pass
|
|
480
|
+
|
|
481
|
+
try:
|
|
482
|
+
size_bytes = p.stat().st_size
|
|
483
|
+
except Exception:
|
|
484
|
+
size_bytes = len(content.encode("utf-8"))
|
|
485
|
+
|
|
486
|
+
_syntax_warn = _verify_python_syntax(p, content)
|
|
487
|
+
_console2, _has_rich2 = _ui()
|
|
488
|
+
if _syntax_warn and _has_rich2 and _console2:
|
|
489
|
+
_console2.print(f" [yellow]⚠ 语法检查未通过[/yellow]")
|
|
490
|
+
|
|
491
|
+
_wdata = {
|
|
492
|
+
"path": str(p),
|
|
493
|
+
"absolute_path": str(p),
|
|
494
|
+
"action": action.lower(),
|
|
495
|
+
"lines": lines,
|
|
496
|
+
"size_bytes": size_bytes,
|
|
497
|
+
"change_id": applied.change_id,
|
|
498
|
+
"before_hash": applied.before_hash,
|
|
499
|
+
"after_hash": applied.after_hash,
|
|
500
|
+
"diff": applied.diff,
|
|
501
|
+
"staged": True,
|
|
502
|
+
"applied": True,
|
|
503
|
+
"user_message": f"文件已保存到: {p} 打开所在目录: {_reveal_hint}",
|
|
504
|
+
}
|
|
505
|
+
if _syntax_warn:
|
|
506
|
+
_wdata["syntax_check"] = "failed"
|
|
507
|
+
return {"success": True, "data": _wdata, "warning": _syntax_warn}
|
|
508
|
+
|
|
509
|
+
_lsp_warn, _lsp_diags = _lsp_autocheck(p)
|
|
510
|
+
if _lsp_warn:
|
|
511
|
+
_print_lsp_diags(_lsp_diags, _console2, _has_rich2)
|
|
512
|
+
_wdata["diagnostics"] = _lsp_diags
|
|
513
|
+
return {"success": True, "data": _wdata, "warning": _lsp_warn}
|
|
514
|
+
return {"success": True, "data": _wdata}
|
|
515
|
+
except Exception as e:
|
|
516
|
+
return {"success": False, "error": str(e)}
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def tool_edit_file(params: dict) -> dict:
|
|
520
|
+
"""Edit a file by replacing old_string with new_string (first occurrence)."""
|
|
521
|
+
path = params.get("path", "")
|
|
522
|
+
old_str = params.get("old_string", params.get("old_str", ""))
|
|
523
|
+
new_str = params.get("new_string", params.get("new_str", ""))
|
|
524
|
+
stage_only = bool(params.get("stage_only", False))
|
|
525
|
+
|
|
526
|
+
if not path:
|
|
527
|
+
return {"success": False, "error": "Missing 'path' parameter"}
|
|
528
|
+
if not old_str:
|
|
529
|
+
return {"success": False, "error": "Missing 'old_string' parameter"}
|
|
530
|
+
|
|
531
|
+
try:
|
|
532
|
+
p = pathlib.Path(path).expanduser().resolve()
|
|
533
|
+
if not p.exists():
|
|
534
|
+
return {"success": False, "error": f"File not found: {p}"}
|
|
535
|
+
if not _is_safe(p):
|
|
536
|
+
return {"success": False, "error": f"Access denied: path '{p}' is outside allowed directories"}
|
|
537
|
+
|
|
538
|
+
content = p.read_text(errors="replace")
|
|
539
|
+
if content.count(old_str) == 0:
|
|
540
|
+
preview = "\n".join(content.splitlines()[:10])
|
|
541
|
+
return {"success": False,
|
|
542
|
+
"error": f"old_string not found in file. "
|
|
543
|
+
f"The file starts with:\n{preview}\n\n"
|
|
544
|
+
f"HINT: Use read_file to see the actual content, then retry edit_file "
|
|
545
|
+
f"with the correct old_string. Or use write_file to overwrite the entire file."}
|
|
546
|
+
|
|
547
|
+
new_content = content.replace(old_str, new_str, 1)
|
|
548
|
+
store = _change_store()
|
|
549
|
+
change = store.stage(p, new_content, source="edit_file")
|
|
550
|
+
added = len(new_str.splitlines())
|
|
551
|
+
removed = len(old_str.splitlines())
|
|
552
|
+
console, has_rich = _ui()
|
|
553
|
+
|
|
554
|
+
if stage_only:
|
|
555
|
+
if has_rich and console:
|
|
556
|
+
console.print(f" [dim]Staged edit {p} (change {change.change_id})[/dim]")
|
|
557
|
+
else:
|
|
558
|
+
print(f" Staged edit {p} (change {change.change_id})")
|
|
559
|
+
return {"success": True, "data": {
|
|
560
|
+
"path": str(p), "replacements": 1,
|
|
561
|
+
"lines": new_content.count("\n") + 1,
|
|
562
|
+
"change_id": change.change_id,
|
|
563
|
+
"before_hash": change.before_hash, "after_hash": change.after_hash,
|
|
564
|
+
"diff": change.diff, "staged": True, "applied": False,
|
|
565
|
+
}}
|
|
566
|
+
|
|
567
|
+
try:
|
|
568
|
+
applied = store.apply(change.change_id)
|
|
569
|
+
except _ChangeConflictError() as exc:
|
|
570
|
+
return {"success": False, "error": str(exc), "data": {"change_id": change.change_id}}
|
|
571
|
+
|
|
572
|
+
if has_rich and console:
|
|
573
|
+
parts = []
|
|
574
|
+
if added > 0:
|
|
575
|
+
parts.append(f"[green]+{added}[/green]")
|
|
576
|
+
if removed > 0:
|
|
577
|
+
parts.append(f"[red]-{removed}[/red]")
|
|
578
|
+
short_path = str(p.name) if len(str(p)) > 60 else str(p)
|
|
579
|
+
console.print(f" [dim]✎ [bold]{short_path}[/bold] ({', '.join(parts)} lines)[/dim]")
|
|
580
|
+
_print_inline_diff(old_str, new_str, console)
|
|
581
|
+
else:
|
|
582
|
+
print(f" Applied (+{added}, -{removed} lines)")
|
|
583
|
+
|
|
584
|
+
_syntax_warn = _verify_python_syntax(p, new_content)
|
|
585
|
+
if _syntax_warn and has_rich and console:
|
|
586
|
+
console.print(f" [yellow]⚠ 语法检查未通过[/yellow]")
|
|
587
|
+
|
|
588
|
+
_data = {
|
|
589
|
+
"path": str(p), "replacements": 1,
|
|
590
|
+
"lines": new_content.count("\n") + 1,
|
|
591
|
+
"change_id": applied.change_id,
|
|
592
|
+
"before_hash": applied.before_hash, "after_hash": applied.after_hash,
|
|
593
|
+
"diff": applied.diff, "staged": True, "applied": True,
|
|
594
|
+
}
|
|
595
|
+
if _syntax_warn:
|
|
596
|
+
_data["syntax_check"] = "failed"
|
|
597
|
+
return {"success": True, "data": _data, "warning": _syntax_warn}
|
|
598
|
+
|
|
599
|
+
# Opt-in LSP diagnostics (catches what the syntax check can't)
|
|
600
|
+
_lsp_warn, _lsp_diags = _lsp_autocheck(p)
|
|
601
|
+
if _lsp_warn:
|
|
602
|
+
_print_lsp_diags(_lsp_diags, console, has_rich)
|
|
603
|
+
_data["diagnostics"] = _lsp_diags
|
|
604
|
+
return {"success": True, "data": _data, "warning": _lsp_warn}
|
|
605
|
+
return {"success": True, "data": _data}
|
|
606
|
+
except Exception as e:
|
|
607
|
+
return {"success": False, "error": str(e)}
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def tool_multi_edit(params: dict) -> dict:
|
|
611
|
+
"""Apply multiple find/replace edits to a single file atomically.
|
|
612
|
+
|
|
613
|
+
All edits must succeed or none are applied (the file is only written once,
|
|
614
|
+
after every edit has been validated against the in-memory content). Edits
|
|
615
|
+
are applied in order, so a later edit can match text introduced by an
|
|
616
|
+
earlier one.
|
|
617
|
+
"""
|
|
618
|
+
path = params.get("path", "")
|
|
619
|
+
edits = params.get("edits", [])
|
|
620
|
+
stage_only = bool(params.get("stage_only", False))
|
|
621
|
+
|
|
622
|
+
if not path:
|
|
623
|
+
return {"success": False, "error": "Missing 'path' parameter"}
|
|
624
|
+
if not isinstance(edits, list) or not edits:
|
|
625
|
+
return {"success": False, "error": "Missing 'edits' — expected a non-empty array of "
|
|
626
|
+
"{old_string, new_string, replace_all?}"}
|
|
627
|
+
|
|
628
|
+
try:
|
|
629
|
+
p = pathlib.Path(path).expanduser().resolve()
|
|
630
|
+
if not p.exists():
|
|
631
|
+
return {"success": False, "error": f"File not found: {p}"}
|
|
632
|
+
if not _is_safe(p):
|
|
633
|
+
return {"success": False, "error": f"Access denied: path '{p}' is outside allowed directories"}
|
|
634
|
+
|
|
635
|
+
content = p.read_text(errors="replace")
|
|
636
|
+
working = content
|
|
637
|
+
applied_count = 0
|
|
638
|
+
total_added = 0
|
|
639
|
+
total_removed = 0
|
|
640
|
+
|
|
641
|
+
# ── Phase 1: validate + apply every edit in memory (atomic) ───────────
|
|
642
|
+
for i, ed in enumerate(edits):
|
|
643
|
+
if not isinstance(ed, dict):
|
|
644
|
+
return {"success": False, "error": f"edit #{i + 1} is not an object"}
|
|
645
|
+
old_s = ed.get("old_string", ed.get("old_str", ""))
|
|
646
|
+
new_s = ed.get("new_string", ed.get("new_str", ""))
|
|
647
|
+
replace_all = bool(ed.get("replace_all", False))
|
|
648
|
+
if not old_s:
|
|
649
|
+
return {"success": False, "error": f"edit #{i + 1} missing 'old_string'"}
|
|
650
|
+
occurrences = working.count(old_s)
|
|
651
|
+
if occurrences == 0:
|
|
652
|
+
return {"success": False, "error":
|
|
653
|
+
f"edit #{i + 1}: old_string not found. No edits applied (atomic). "
|
|
654
|
+
f"HINT: read_file first to copy exact text. old_string was:\n"
|
|
655
|
+
f"{old_s[:200]}"}
|
|
656
|
+
if occurrences > 1 and not replace_all:
|
|
657
|
+
return {"success": False, "error":
|
|
658
|
+
f"edit #{i + 1}: old_string matches {occurrences} places — pass "
|
|
659
|
+
f"replace_all=true to replace all, or make old_string unique."}
|
|
660
|
+
working = working.replace(old_s, new_s, -1 if replace_all else 1)
|
|
661
|
+
applied_count += 1
|
|
662
|
+
total_added += len(new_s.splitlines())
|
|
663
|
+
total_removed += len(old_s.splitlines())
|
|
664
|
+
|
|
665
|
+
if working == content:
|
|
666
|
+
return {"success": False, "error": "No changes — edits produced identical content."}
|
|
667
|
+
|
|
668
|
+
# ── Phase 2: stage + apply once ───────────────────────────────────────
|
|
669
|
+
store = _change_store()
|
|
670
|
+
change = store.stage(p, working, source="multi_edit")
|
|
671
|
+
console, has_rich = _ui()
|
|
672
|
+
|
|
673
|
+
if stage_only:
|
|
674
|
+
return {"success": True, "data": {
|
|
675
|
+
"path": str(p), "edits_applied": applied_count,
|
|
676
|
+
"lines": working.count("\n") + 1,
|
|
677
|
+
"change_id": change.change_id,
|
|
678
|
+
"before_hash": change.before_hash, "after_hash": change.after_hash,
|
|
679
|
+
"diff": change.diff, "staged": True, "applied": False,
|
|
680
|
+
}}
|
|
681
|
+
|
|
682
|
+
try:
|
|
683
|
+
applied = store.apply(change.change_id)
|
|
684
|
+
except _ChangeConflictError() as exc:
|
|
685
|
+
return {"success": False, "error": str(exc), "data": {"change_id": change.change_id}}
|
|
686
|
+
|
|
687
|
+
if has_rich and console:
|
|
688
|
+
console.print(f" [dim]Applied {applied_count} edits "
|
|
689
|
+
f"([green]+{total_added}[/green]/[red]-{total_removed}[/red] lines)[/dim]")
|
|
690
|
+
else:
|
|
691
|
+
print(f" Applied {applied_count} edits (+{total_added}/-{total_removed} lines)")
|
|
692
|
+
|
|
693
|
+
_syntax_warn = _verify_python_syntax(p, working)
|
|
694
|
+
if _syntax_warn and has_rich and console:
|
|
695
|
+
console.print(f" [yellow]⚠ 语法检查未通过[/yellow]")
|
|
696
|
+
|
|
697
|
+
_data = {
|
|
698
|
+
"path": str(p), "edits_applied": applied_count,
|
|
699
|
+
"lines": working.count("\n") + 1,
|
|
700
|
+
"change_id": applied.change_id,
|
|
701
|
+
"before_hash": applied.before_hash, "after_hash": applied.after_hash,
|
|
702
|
+
"diff": applied.diff, "staged": True, "applied": True,
|
|
703
|
+
}
|
|
704
|
+
if _syntax_warn:
|
|
705
|
+
_data["syntax_check"] = "failed"
|
|
706
|
+
return {"success": True, "data": _data, "warning": _syntax_warn}
|
|
707
|
+
|
|
708
|
+
_lsp_warn, _lsp_diags = _lsp_autocheck(p)
|
|
709
|
+
if _lsp_warn:
|
|
710
|
+
_print_lsp_diags(_lsp_diags, console, has_rich)
|
|
711
|
+
_data["diagnostics"] = _lsp_diags
|
|
712
|
+
return {"success": True, "data": _data, "warning": _lsp_warn}
|
|
713
|
+
return {"success": True, "data": _data}
|
|
714
|
+
except Exception as e:
|
|
715
|
+
return {"success": False, "error": str(e)}
|