lcode-agent 0.1.0__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.
lcode/tools.py ADDED
@@ -0,0 +1,1147 @@
1
+ """Grok Build 同名内置工具:读改搜列、终端、待办、网页。
2
+
3
+ 参数名和 Grok 对齐。相对路径相对当前工作目录,也允许项目外的绝对路径。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import difflib
10
+ import shutil
11
+ import sys
12
+ import json
13
+ import os
14
+ import re
15
+ import subprocess
16
+ from pathlib import Path
17
+
18
+ import aiohttp
19
+
20
+ ROOT = Path.cwd().resolve()
21
+ _SKIP_DIR = {".git", "__pycache__", ".venv", "node_modules", ".lcode"}
22
+ _before_write = None
23
+
24
+
25
+ def set_write_hook(fn) -> None:
26
+ """改文件前打快照。fn(path: Path) -> None。"""
27
+ global _before_write
28
+ _before_write = fn
29
+
30
+
31
+ def _note_write(path: Path) -> None:
32
+ hook = _before_write
33
+ if hook is None:
34
+ return
35
+ try:
36
+ hook(path)
37
+ except Exception:
38
+ pass
39
+
40
+
41
+ def _safe_path(raw: str) -> Path:
42
+ text = (raw or "").strip() or "."
43
+ path = Path(text)
44
+ if not path.is_absolute():
45
+ path = ROOT / path
46
+ return path.resolve()
47
+
48
+
49
+ def _clip(text: str, limit: int = 80_000) -> str:
50
+ if len(text) <= limit:
51
+ return text
52
+ return text[:limit] + f"\n…(截断,共 {len(text)} 字)"
53
+
54
+
55
+ def openai_tools() -> list[dict]:
56
+ """chat/completions 用的 tools 数组。"""
57
+ return [{"type": "function", "function": spec} for spec in _SPECS]
58
+
59
+
60
+ def responses_tools() -> list[dict]:
61
+ """responses 用的 tools 数组。"""
62
+ out = []
63
+ for spec in _SPECS:
64
+ out.append(
65
+ {
66
+ "type": "function",
67
+ "name": spec["name"],
68
+ "description": spec["description"],
69
+ "parameters": spec["parameters"],
70
+ }
71
+ )
72
+ return out
73
+
74
+
75
+ _SPECS: list[dict] = [
76
+ {
77
+ "name": "read_file",
78
+ "description": "Read a file. Line numbers appear as LINE|content.",
79
+ "parameters": {
80
+ "type": "object",
81
+ "properties": {
82
+ "target_file": {"type": "string", "description": "Path relative to workspace or absolute."},
83
+ "offset": {"type": "integer", "description": "1-based start line."},
84
+ "limit": {"type": "integer", "description": "Max lines to read."},
85
+ },
86
+ "required": ["target_file"],
87
+ },
88
+ },
89
+ {
90
+ "name": "write",
91
+ "description": "Create or overwrite a file. Parent directories are created. Prefer search_replace for small edits.",
92
+ "parameters": {
93
+ "type": "object",
94
+ "properties": {
95
+ "file_path": {"type": "string"},
96
+ "contents": {"type": "string", "description": "Full file contents."},
97
+ },
98
+ "required": ["file_path", "contents"],
99
+ },
100
+ },
101
+ {
102
+ "name": "write_file",
103
+ "description": "Alias of write.",
104
+ "parameters": {
105
+ "type": "object",
106
+ "properties": {
107
+ "file_path": {"type": "string"},
108
+ "contents": {"type": "string"},
109
+ },
110
+ "required": ["file_path", "contents"],
111
+ },
112
+ },
113
+ {
114
+ "name": "search_replace",
115
+ "description": "Replace an exact string in a file. old_string must match once unless replace_all.",
116
+ "parameters": {
117
+ "type": "object",
118
+ "properties": {
119
+ "file_path": {"type": "string"},
120
+ "old_string": {"type": "string"},
121
+ "new_string": {"type": "string"},
122
+ "replace_all": {"type": "boolean", "default": False},
123
+ },
124
+ "required": ["file_path", "old_string", "new_string"],
125
+ },
126
+ },
127
+ {
128
+ "name": "list_dir",
129
+ "description": "List files and directories. Dot files hidden. gitignored-style junk skipped.",
130
+ "parameters": {
131
+ "type": "object",
132
+ "properties": {
133
+ "target_directory": {"type": "string"},
134
+ },
135
+ "required": ["target_directory"],
136
+ },
137
+ },
138
+ {
139
+ "name": "grep",
140
+ "description": "Search file contents with a regex. ripgrep-style filters.",
141
+ "parameters": {
142
+ "type": "object",
143
+ "properties": {
144
+ "pattern": {"type": "string"},
145
+ "path": {"type": "string"},
146
+ "glob": {"type": "string"},
147
+ "head_limit": {"type": "integer"},
148
+ "-i": {"type": "boolean"},
149
+ },
150
+ "required": ["pattern"],
151
+ },
152
+ },
153
+ {
154
+ "name": "grep_search",
155
+ "description": "Alias of grep.",
156
+ "parameters": {
157
+ "type": "object",
158
+ "properties": {
159
+ "query": {"type": "string"},
160
+ "search_path": {"type": "string"},
161
+ "include_pattern": {"type": "string"},
162
+ },
163
+ "required": ["query"],
164
+ },
165
+ },
166
+ {
167
+ "name": "run_terminal_command",
168
+ "description": "Run a shell command in the workspace. Use for git, builds, scripts.",
169
+ "parameters": {
170
+ "type": "object",
171
+ "properties": {
172
+ "command": {"type": "string"},
173
+ "working_directory": {"type": "string"},
174
+ "timeout": {"type": "integer", "description": "Seconds, default 120."},
175
+ },
176
+ "required": ["command"],
177
+ },
178
+ },
179
+ {
180
+ "name": "bash",
181
+ "description": "Alias of run_terminal_command.",
182
+ "parameters": {
183
+ "type": "object",
184
+ "properties": {
185
+ "command": {"type": "string"},
186
+ "working_directory": {"type": "string"},
187
+ },
188
+ "required": ["command"],
189
+ },
190
+ },
191
+ {
192
+ "name": "todo_write",
193
+ "description": "Create and manage a structured task list.",
194
+ "parameters": {
195
+ "type": "object",
196
+ "properties": {
197
+ "todos": {
198
+ "type": "array",
199
+ "items": {
200
+ "type": "object",
201
+ "properties": {
202
+ "id": {"type": "string"},
203
+ "content": {"type": "string"},
204
+ "status": {
205
+ "type": "string",
206
+ "enum": ["pending", "in_progress", "completed", "cancelled"],
207
+ },
208
+ },
209
+ "required": ["id"],
210
+ },
211
+ },
212
+ },
213
+ "required": ["todos"],
214
+ },
215
+ },
216
+ {
217
+ "name": "web_search",
218
+ "description": "Search the web. Use for current facts.",
219
+ "parameters": {
220
+ "type": "object",
221
+ "properties": {
222
+ "query": {"type": "string"},
223
+ "num_results": {"type": "integer", "default": 5},
224
+ },
225
+ "required": ["query"],
226
+ },
227
+ },
228
+ {
229
+ "name": "web_fetch",
230
+ "description": "Fetch a URL and return text content.",
231
+ "parameters": {
232
+ "type": "object",
233
+ "properties": {"url": {"type": "string"}},
234
+ "required": ["url"],
235
+ },
236
+ },
237
+ ]
238
+
239
+
240
+ _TODOS: list[dict] = []
241
+ _todo_change_hook = None
242
+
243
+
244
+ def set_todo_hook(fn) -> None:
245
+ """todo_write 变化后通知界面/存储。fn(todos: list[dict]) -> None。"""
246
+ global _todo_change_hook
247
+ _todo_change_hook = fn
248
+
249
+
250
+ def set_todos(initial: list[dict]) -> None:
251
+ """启动时把落库的待办灌回内存。"""
252
+ global _TODOS
253
+ _TODOS = [t for t in (initial or []) if isinstance(t, dict)]
254
+ hook = _todo_change_hook
255
+ if hook is not None:
256
+ try:
257
+ hook(_TODOS)
258
+ except Exception:
259
+ pass
260
+
261
+
262
+ def execute_tool(name: str, arguments: dict) -> str:
263
+ key = (name or "").strip()
264
+ if key == "grep_search":
265
+ arguments = {
266
+ "pattern": arguments.get("query") or arguments.get("pattern") or "",
267
+ "path": arguments.get("search_path") or arguments.get("path"),
268
+ "glob": arguments.get("include_pattern") or arguments.get("glob"),
269
+ }
270
+ key = "grep"
271
+ if key == "bash":
272
+ key = "run_terminal_command"
273
+ if key == "write_file":
274
+ key = "write"
275
+ fn = {
276
+ "read_file": _read_file,
277
+ "write": _write_file,
278
+ "search_replace": _search_replace,
279
+ "list_dir": _list_dir,
280
+ "grep": _grep,
281
+ "run_terminal_command": _run_cmd,
282
+ "todo_write": _todo_write,
283
+ "web_search": None,
284
+ "web_fetch": None,
285
+ }.get(key)
286
+ if key in ("web_search", "web_fetch"):
287
+ raise RuntimeError("async")
288
+ if fn is None:
289
+ return f"未知工具: {name}"
290
+ try:
291
+ return fn(arguments or {})
292
+ except Exception as exc:
293
+ return f"工具失败: {exc}"
294
+
295
+
296
+ async def execute_tool_async(name: str, arguments: dict, on_output=None) -> str:
297
+ key = (name or "").strip()
298
+ if key == "web_search":
299
+ return await _web_search(arguments or {})
300
+ if key == "web_fetch":
301
+ return await _web_fetch(arguments or {})
302
+ if key in ("run_terminal_command", "bash"):
303
+ return await _run_cmd_async(arguments or {}, on_output)
304
+ # 终端、文件 IO 这些同步工具丢进线程池,别把 TUI 的事件循环冻住
305
+ return await asyncio.to_thread(execute_tool, name, arguments or {})
306
+
307
+
308
+ def _relpath(path: Path) -> str:
309
+ try:
310
+ return str(path.relative_to(ROOT)).replace("\\", "/")
311
+ except ValueError:
312
+ return str(path)
313
+
314
+
315
+ def tool_title(name: str, arguments: dict) -> str:
316
+ args = arguments or {}
317
+ if name in ("read_file",):
318
+ return f"read_file {args.get('target_file') or ''}".strip()
319
+ if name in ("write", "write_file"):
320
+ raw = str(args.get("file_path") or args.get("target_file") or "")
321
+ try:
322
+ return f"write {_relpath(_safe_path(raw))}"
323
+ except Exception:
324
+ return f"write {raw}".strip()
325
+ if name == "search_replace":
326
+ raw = str(args.get("file_path") or "")
327
+ try:
328
+ return f"search_replace {_relpath(_safe_path(raw))}"
329
+ except Exception:
330
+ return f"search_replace {raw}".strip()
331
+ if name == "list_dir":
332
+ return f"list_dir {args.get('target_directory') or ''}".strip()
333
+ if name in ("grep", "grep_search"):
334
+ return f"grep {args.get('pattern') or args.get('query') or ''}".strip()
335
+ if name in ("run_terminal_command", "bash"):
336
+ cmd = str(args.get("command") or "")
337
+ return f"$ {cmd[:80]}"
338
+ if name == "todo_write":
339
+ return "todo_write"
340
+ if name == "web_search":
341
+ return f"web_search {args.get('query') or ''}".strip()
342
+ if name == "web_fetch":
343
+ return f"web_fetch {args.get('url') or ''}".strip()
344
+ return name
345
+
346
+
347
+ def tool_result_summary(name: str, result: str) -> str:
348
+ first = (result or "").splitlines()[0] if result else ""
349
+ key = (name or "").strip()
350
+ if key == "search_replace":
351
+ if first.startswith("已替换"):
352
+ return first.split(":", 1)[0].strip()
353
+ return first[:40]
354
+ if key in ("write", "write_file"):
355
+ if first.startswith("已写入") or first.startswith("已覆盖"):
356
+ return first.split(":", 1)[0].strip()
357
+ return first[:40]
358
+ if key == "read_file":
359
+ if not result:
360
+ return "空"
361
+ return f"{result.count(chr(10)) + 1} 行"
362
+ if key == "list_dir":
363
+ n = len([ln for ln in (result or "").splitlines() if ln.strip()])
364
+ return f"{n} 项"
365
+ if key in ("grep", "grep_search"):
366
+ if first == "无匹配":
367
+ return "无匹配"
368
+ return f"{len((result or '').splitlines())} 处"
369
+ if key in ("run_terminal_command", "bash"):
370
+ return first[:32] if first else "完成"
371
+ if key == "todo_write":
372
+ return f"{len((result or '').splitlines())} 项"
373
+ return first[:40]
374
+
375
+
376
+ def normalize_tool_name(name: str) -> str:
377
+ key = (name or "").strip()
378
+ if key == "write_file":
379
+ return "write"
380
+ if key == "bash":
381
+ return "run_terminal_command"
382
+ if key == "grep_search":
383
+ return "grep"
384
+ return key
385
+
386
+
387
+ def tool_is_sensitive(name: str, arguments: dict | None = None) -> bool:
388
+ """ask 模式下要先问用户的操作:改文件、覆盖文件、跑终端。"""
389
+ key = normalize_tool_name(name)
390
+ return key in ("run_terminal_command", "search_replace", "write")
391
+
392
+
393
+ _JSON_ESC = {
394
+ '"': '"',
395
+ "\\": "\\",
396
+ "/": "/",
397
+ "b": "\b",
398
+ "f": "\f",
399
+ "n": "\n",
400
+ "r": "\r",
401
+ "t": "\t",
402
+ }
403
+
404
+ _TOOL_STRING_KEYS = (
405
+ "file_path",
406
+ "target_file",
407
+ "target_directory",
408
+ "path",
409
+ "contents",
410
+ "content",
411
+ "old_string",
412
+ "new_string",
413
+ "command",
414
+ "working_directory",
415
+ "pattern",
416
+ "query",
417
+ "url",
418
+ )
419
+
420
+
421
+ def unescape_json_fragment(raw: str) -> str:
422
+ """把 JSON 字符串里的 \\n \\t \\\" 还原成真正字符;末尾半截转义丢掉。"""
423
+ out: list[str] = []
424
+ i = 0
425
+ n = len(raw or "")
426
+ while i < n:
427
+ ch = raw[i]
428
+ if ch != "\\":
429
+ out.append(ch)
430
+ i += 1
431
+ continue
432
+ if i + 1 >= n:
433
+ break
434
+ nxt = raw[i + 1]
435
+ if nxt == "u":
436
+ if i + 5 >= n:
437
+ break
438
+ try:
439
+ out.append(chr(int(raw[i + 2 : i + 6], 16)))
440
+ except ValueError:
441
+ out.append(nxt)
442
+ i += 2
443
+ continue
444
+ i += 6
445
+ continue
446
+ out.append(_JSON_ESC.get(nxt, nxt))
447
+ i += 2
448
+ return "".join(out)
449
+
450
+
451
+ def _json_string_body(raw: str, start: int) -> str:
452
+ """取开始引号之后到未转义结束引号之前的原文(含转义);没闭合就接到末尾。"""
453
+ i = start
454
+ n = len(raw)
455
+ while i < n:
456
+ ch = raw[i]
457
+ if ch == '"':
458
+ return raw[start:i]
459
+ if ch == "\\":
460
+ i += 2
461
+ continue
462
+ i += 1
463
+ return raw[start:]
464
+
465
+
466
+ def _extract_json_string(raw: str, key: str) -> str | None:
467
+ needle = f'"{key}"'
468
+ start = 0
469
+ while True:
470
+ idx = raw.find(needle, start)
471
+ if idx < 0:
472
+ return None
473
+ i = idx + len(needle)
474
+ n = len(raw)
475
+ while i < n and raw[i] in " \t\r\n":
476
+ i += 1
477
+ if i >= n or raw[i] != ":":
478
+ start = idx + 1
479
+ continue
480
+ i += 1
481
+ while i < n and raw[i] in " \t\r\n":
482
+ i += 1
483
+ if i >= n or raw[i] != '"':
484
+ start = idx + 1
485
+ continue
486
+ return unescape_json_fragment(_json_string_body(raw, i + 1))
487
+
488
+
489
+ def parse_tool_arguments(raw: str) -> dict:
490
+ """完整 JSON 直接解析;流式半截则尽量抽出字符串字段,\\n 已经还原。"""
491
+ text = raw or ""
492
+ stripped = text.strip()
493
+ if not stripped:
494
+ return {}
495
+ try:
496
+ data = json.loads(stripped)
497
+ return data if isinstance(data, dict) else {}
498
+ except json.JSONDecodeError:
499
+ pass
500
+ out: dict = {}
501
+ for key in _TOOL_STRING_KEYS:
502
+ val = _extract_json_string(text, key)
503
+ if val is not None:
504
+ out[key] = val
505
+ return out
506
+
507
+
508
+ def looks_like_tool_args(text: str) -> bool:
509
+ """助手正文里误塞进来的工具 JSON:以 { 开头,带 contents/command 这类键。"""
510
+ s = (text or "").lstrip()
511
+ if not s.startswith("{"):
512
+ return False
513
+ keys = (
514
+ '"contents"',
515
+ '"new_string"',
516
+ '"old_string"',
517
+ '"file_path"',
518
+ '"command"',
519
+ '"target_file"',
520
+ )
521
+ return any(k in s for k in keys)
522
+
523
+
524
+ def _preview_rel(path: str) -> str:
525
+ raw = (path or "").strip()
526
+ if not raw:
527
+ return ""
528
+ try:
529
+ return _relpath(_safe_path(raw))
530
+ except Exception:
531
+ return raw
532
+
533
+
534
+ def preview_tool_arguments(name: str, raw: str) -> str:
535
+ """流式工具参数给人看:写入/替换从第一块起就按真换行展示,不要字面 \\n。"""
536
+ args = parse_tool_arguments(raw)
537
+ key = normalize_tool_name(name)
538
+ if not key:
539
+ if "new_string" in args or "old_string" in args:
540
+ key = "search_replace"
541
+ elif "contents" in args or "content" in args:
542
+ key = "write"
543
+ elif "command" in args:
544
+ key = "run_terminal_command"
545
+ if key == "write":
546
+ rel = _preview_rel(
547
+ str(args.get("file_path") or args.get("target_file") or args.get("path") or "")
548
+ )
549
+ body = args.get("contents")
550
+ if body is None:
551
+ body = args.get("content")
552
+ body = str(body or "")
553
+ lines = body.splitlines()
554
+ if body and not lines:
555
+ lines = [body]
556
+ rows = [f"+{i:>5}|{line}" for i, line in enumerate(lines, start=1)]
557
+ header = f"写入中: {rel}" if rel else "写入中"
558
+ if not rows:
559
+ return header
560
+ return f"{header}\n\n" + "\n".join(_clip_rows(rows))
561
+ if key == "search_replace":
562
+ rel = _preview_rel(str(args.get("file_path") or ""))
563
+ old = str(args.get("old_string") or "")
564
+ new = str(args.get("new_string") or "")
565
+ rows: list[str] = []
566
+ if old:
567
+ for line in old.splitlines() or [old]:
568
+ rows.append(f"-{line}")
569
+ if new:
570
+ for line in new.splitlines() or [new]:
571
+ rows.append(f"+{line}")
572
+ header = f"替换中: {rel}" if rel else "替换中"
573
+ if not rows:
574
+ return header
575
+ return f"{header}\n\n" + "\n".join(_clip_rows(rows))
576
+ if key == "run_terminal_command":
577
+ cmd = str(args.get("command") or "")
578
+ cwd = str(args.get("working_directory") or "")
579
+ if cmd and cwd:
580
+ return f"{cmd}\n(cwd {cwd})"
581
+ return cmd or (f"cwd {cwd}" if cwd else unescape_json_fragment(raw or ""))
582
+ if args:
583
+ try:
584
+ return json.dumps(args, ensure_ascii=False, indent=2)
585
+ except (TypeError, ValueError):
586
+ pass
587
+ return unescape_json_fragment(raw or "")
588
+
589
+
590
+ def pretty_stream_text(text: str) -> str:
591
+ """历史里误存的工具 JSON,展示时也按换行还原。"""
592
+ if not looks_like_tool_args(text):
593
+ return text
594
+ guessed = "write"
595
+ if '"new_string"' in text or '"old_string"' in text:
596
+ guessed = "search_replace"
597
+ elif '"command"' in text and '"contents"' not in text and '"content"' not in text:
598
+ guessed = "run_terminal_command"
599
+ preview = preview_tool_arguments(guessed, text)
600
+ return preview or unescape_json_fragment(text)
601
+
602
+
603
+ def permit_preview(name: str, arguments: dict | None = None) -> str:
604
+ args = arguments or {}
605
+ key = normalize_tool_name(name)
606
+ if key == "run_terminal_command":
607
+ cmd = str(args.get("command") or "").strip()
608
+ return f"$ {cmd}" if cmd else "$ (空命令)"
609
+ if key == "write":
610
+ path = str(args.get("file_path") or args.get("target_file") or "")
611
+ try:
612
+ dest = _safe_path(path)
613
+ kind = "覆盖" if dest.is_file() else "新建"
614
+ return f"write {kind} {_relpath(dest)}"
615
+ except Exception:
616
+ return f"write {path}"
617
+ if key == "search_replace":
618
+ path = str(args.get("file_path") or "")
619
+ try:
620
+ return f"search_replace {_relpath(_safe_path(path))}"
621
+ except Exception:
622
+ return f"search_replace {path}"
623
+ return tool_title(name, args)
624
+
625
+
626
+ _DIFF_CONTEXT = 4
627
+ _MAX_HUNKS = 12
628
+ _MAX_HUNK_ROWS = 120
629
+
630
+
631
+ def _line_no(text: str, pos: int) -> int:
632
+ return text.count("\n", 0, max(0, pos)) + 1
633
+
634
+
635
+ def _full_line_span(text: str, start: int, end: int) -> tuple[int, int]:
636
+ if end < start:
637
+ end = start
638
+ last = end - 1 if end > start else start
639
+ if text:
640
+ last = min(last, len(text) - 1)
641
+ line_start = text.rfind("\n", 0, start) + 1
642
+ nl = text.find("\n", last)
643
+ line_end = nl if nl >= 0 else len(text)
644
+ return line_start, line_end
645
+
646
+
647
+ def _context_before(text: str, pos: int, n: int) -> tuple[list[str], int]:
648
+ start_line = _line_no(text, pos)
649
+ if pos <= 0 or n <= 0:
650
+ return [], start_line
651
+ prefix = text[:pos]
652
+ if prefix.endswith("\n"):
653
+ prefix = prefix[:-1]
654
+ lines = prefix.splitlines()
655
+ chunk = lines[-n:]
656
+ return chunk, start_line - len(chunk)
657
+
658
+
659
+ def _context_after(text: str, pos: int, n: int) -> list[str]:
660
+ if n <= 0 or pos >= len(text):
661
+ return []
662
+ suffix = text[pos:]
663
+ if suffix.startswith("\n"):
664
+ suffix = suffix[1:]
665
+ return suffix.splitlines()[:n]
666
+
667
+
668
+ def _clip_rows(rows: list[str], cap: int = _MAX_HUNK_ROWS) -> list[str]:
669
+ if len(rows) <= cap:
670
+ return rows
671
+ keep = cap // 2
672
+ omitted = len(rows) - keep * 2
673
+ return rows[:keep] + [f" … ({omitted} 行)"] + rows[-keep:]
674
+
675
+
676
+ def build_replace_hunks(
677
+ text: str,
678
+ old: str,
679
+ new: str,
680
+ *,
681
+ context: int = _DIFF_CONTEXT,
682
+ replace_all: bool = False,
683
+ ) -> list[str]:
684
+ """把这次替换画成带上下文的 +/- 块,给终端和模型看。"""
685
+ if not old:
686
+ return []
687
+ hunks: list[str] = []
688
+ start_from = 0
689
+ while True:
690
+ idx = text.find(old, start_from)
691
+ if idx < 0:
692
+ break
693
+ end = idx + len(old)
694
+ ls, le = _full_line_span(text, idx, end)
695
+ old_block = text[ls:le]
696
+ new_block = text[ls:idx] + new + text[end:le]
697
+ old_lines = old_block.splitlines()
698
+ new_lines = new_block.splitlines()
699
+ if old_block != "" and not old_lines:
700
+ old_lines = [""]
701
+ if new_block != "" and not new_lines:
702
+ new_lines = [""]
703
+ before, first_ln = _context_before(text, ls, context)
704
+ after = _context_after(text, le, context)
705
+ old_all = before + old_lines + after
706
+ new_all = before + new_lines + after
707
+ matcher = difflib.SequenceMatcher(a=old_all, b=new_all, autojunk=False)
708
+ old_ln = first_ln
709
+ new_ln = first_ln
710
+ rows: list[str] = []
711
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
712
+ if tag == "equal":
713
+ for line in new_all[j1:j2]:
714
+ rows.append(f" {new_ln:>5}|{line}")
715
+ new_ln += 1
716
+ old_ln += 1
717
+ elif tag == "delete":
718
+ for line in old_all[i1:i2]:
719
+ rows.append(f"-{old_ln:>5}|{line}")
720
+ old_ln += 1
721
+ elif tag == "insert":
722
+ for line in new_all[j1:j2]:
723
+ rows.append(f"+{new_ln:>5}|{line}")
724
+ new_ln += 1
725
+ else:
726
+ for line in old_all[i1:i2]:
727
+ rows.append(f"-{old_ln:>5}|{line}")
728
+ old_ln += 1
729
+ for line in new_all[j1:j2]:
730
+ rows.append(f"+{new_ln:>5}|{line}")
731
+ new_ln += 1
732
+ rows = _clip_rows(rows)
733
+ if rows:
734
+ hunks.append("\n".join(rows))
735
+ start_from = end
736
+ if not replace_all or old == new:
737
+ break
738
+ if len(hunks) >= _MAX_HUNKS:
739
+ break
740
+ return hunks
741
+
742
+
743
+ def format_replace_result(
744
+ path: Path,
745
+ text: str,
746
+ old: str,
747
+ new: str,
748
+ *,
749
+ count: int,
750
+ replace_all: bool,
751
+ ) -> str:
752
+ rel = _relpath(path)
753
+ hunks = build_replace_hunks(text, old, new, replace_all=replace_all)
754
+ header = f"已替换 {count} 处: {rel}"
755
+ extra = ""
756
+ if count > len(hunks) > 0:
757
+ extra = f"\n…还有 {count - len(hunks)} 处"
758
+ body = "\n\n".join(hunks)
759
+ if body:
760
+ return f"{header}\n\n{body}{extra}"
761
+ return header
762
+
763
+
764
+ def _read_file(args: dict) -> str:
765
+ path = _safe_path(str(args.get("target_file") or ""))
766
+ if not path.is_file():
767
+ return f"不是文件: {path}"
768
+ text = path.read_text(encoding="utf-8", errors="replace")
769
+ lines = text.splitlines()
770
+ offset = int(args.get("offset") or 1)
771
+ if offset < 1:
772
+ offset = 1
773
+ limit = args.get("limit")
774
+ end = offset - 1 + int(limit) if limit else len(lines)
775
+ chunk = lines[offset - 1 : end]
776
+ out = []
777
+ for i, line in enumerate(chunk, start=offset):
778
+ out.append(f"{i:>6}|{line}")
779
+ return _clip("\n".join(out) or "(空文件)")
780
+
781
+
782
+ def _search_replace(args: dict) -> str:
783
+ path = _safe_path(str(args.get("file_path") or ""))
784
+ old = str(args.get("old_string") or "")
785
+ new = str(args.get("new_string") or "")
786
+ replace_all = bool(args.get("replace_all"))
787
+ if not path.is_file():
788
+ return f"文件不存在: {_relpath(path)}"
789
+ text = path.read_text(encoding="utf-8", errors="replace")
790
+ if not old:
791
+ return "old_string 为空"
792
+ count = text.count(old)
793
+ if count == 0:
794
+ return "找不到 old_string,未修改"
795
+ if count > 1 and not replace_all:
796
+ return f"old_string 出现 {count} 次,请加 replace_all 或写更长的上下文"
797
+ preview = format_replace_result(
798
+ path, text, old, new, count=count, replace_all=replace_all
799
+ )
800
+ _note_write(path)
801
+ if replace_all:
802
+ path.write_text(text.replace(old, new), encoding="utf-8")
803
+ else:
804
+ path.write_text(text.replace(old, new, 1), encoding="utf-8")
805
+ return preview
806
+
807
+
808
+ def format_write_result(path: Path, old: str, new: str, *, created: bool) -> str:
809
+ rel = _relpath(path)
810
+ if created:
811
+ header = f"已写入 新文件: {rel}"
812
+ lines = new.splitlines()
813
+ if not lines and new:
814
+ lines = [new]
815
+ rows = [f"+{i:>5}|{line}" for i, line in enumerate(lines, start=1)]
816
+ body = "\n".join(_clip_rows(rows))
817
+ return f"{header}\n\n{body}" if body else header
818
+ if old == new:
819
+ return f"已覆盖: {rel}(内容相同)"
820
+ old_lines = old.splitlines()
821
+ new_lines = new.splitlines()
822
+ matcher = difflib.SequenceMatcher(a=old_lines, b=new_lines, autojunk=False)
823
+ old_ln = 1
824
+ new_ln = 1
825
+ rows: list[str] = []
826
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
827
+ if tag == "equal":
828
+ for line in new_lines[j1:j2]:
829
+ rows.append(f" {new_ln:>5}|{line}")
830
+ new_ln += 1
831
+ old_ln += 1
832
+ elif tag == "delete":
833
+ for line in old_lines[i1:i2]:
834
+ rows.append(f"-{old_ln:>5}|{line}")
835
+ old_ln += 1
836
+ elif tag == "insert":
837
+ for line in new_lines[j1:j2]:
838
+ rows.append(f"+{new_ln:>5}|{line}")
839
+ new_ln += 1
840
+ else:
841
+ for line in old_lines[i1:i2]:
842
+ rows.append(f"-{old_ln:>5}|{line}")
843
+ old_ln += 1
844
+ for line in new_lines[j1:j2]:
845
+ rows.append(f"+{new_ln:>5}|{line}")
846
+ new_ln += 1
847
+ body = "\n".join(_clip_rows(rows))
848
+ header = f"已覆盖: {rel}"
849
+ return f"{header}\n\n{body}" if body else header
850
+
851
+
852
+ def _write_file(args: dict) -> str:
853
+ raw_path = str(args.get("file_path") or args.get("target_file") or args.get("path") or "")
854
+ path = _safe_path(raw_path)
855
+ contents = args.get("contents")
856
+ if contents is None:
857
+ contents = args.get("content")
858
+ if contents is None:
859
+ return "缺少 contents"
860
+ text = str(contents)
861
+ created = not path.is_file()
862
+ old = ""
863
+ if not created:
864
+ old = path.read_text(encoding="utf-8", errors="replace")
865
+ _note_write(path)
866
+ path.parent.mkdir(parents=True, exist_ok=True)
867
+ path.write_text(text, encoding="utf-8")
868
+ return format_write_result(path, old, text, created=created)
869
+
870
+
871
+ def _list_dir(args: dict) -> str:
872
+ path = _safe_path(str(args.get("target_directory") or "."))
873
+ if not path.is_dir():
874
+ return f"不是目录: {path}"
875
+ names = []
876
+ for entry in sorted(path.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):
877
+ if entry.name.startswith("."):
878
+ continue
879
+ if entry.name in _SKIP_DIR:
880
+ continue
881
+ mark = "/" if entry.is_dir() else ""
882
+ names.append(entry.name + mark)
883
+ return "\n".join(names) or "(空目录)"
884
+
885
+
886
+ def _rg_path() -> str:
887
+ """本机装了 ripgrep 就用它;没有返回空串,走纯 Python 的慢路径。"""
888
+ return shutil.which("rg") or ""
889
+
890
+
891
+ def _grep_rg(rg: str, pattern: str, root: Path, glob: str, ignore_case: bool, limit: int) -> str | None:
892
+ """ripgrep 搜索:自动尊重 .gitignore,跳过二进制,快一个数量级。
893
+ 返回 None 表示 rg 失败(没装或报错),调用方回退到 _grep 慢路径。"""
894
+ argv = [rg, "--line-number", "--no-heading", "--color", "never"]
895
+ if ignore_case:
896
+ argv.append("-i")
897
+ if glob:
898
+ argv += ["--glob", glob]
899
+ argv += ["--", pattern, str(root)]
900
+ try:
901
+ proc = subprocess.run(
902
+ argv,
903
+ cwd=str(ROOT),
904
+ capture_output=True,
905
+ text=True,
906
+ timeout=30,
907
+ encoding="utf-8",
908
+ errors="replace",
909
+ )
910
+ except (OSError, subprocess.SubprocessError):
911
+ return None
912
+ if proc.returncode not in (0, 1):
913
+ return None
914
+ if proc.returncode == 1 or not proc.stdout.strip():
915
+ return "无匹配"
916
+ # head_limit 和慢路径同义:总共最多 limit 行
917
+ out = "\n".join(proc.stdout.rstrip().splitlines()[: max(1, limit)])
918
+ # 绝对路径统一转成相对路径展示
919
+ rel_root = str(ROOT)
920
+ if out.startswith(rel_root):
921
+ out = out[len(rel_root) + 1:]
922
+ return _clip(out)
923
+
924
+
925
+ def _grep(args: dict) -> str:
926
+ pattern = str(args.get("pattern") or "")
927
+ if not pattern:
928
+ return "缺少 pattern"
929
+ flags = re.IGNORECASE if args.get("-i") or args.get("i") else 0
930
+ try:
931
+ rx = re.compile(pattern, flags)
932
+ except re.error as exc:
933
+ return f"正则无效: {exc}"
934
+ root = _safe_path(str(args.get("path") or "."))
935
+ glob = str(args.get("glob") or "")
936
+ limit = int(args.get("head_limit") or 200)
937
+ rg = _rg_path()
938
+ if rg and not root.is_file():
939
+ hit = _grep_rg(rg, pattern, root, glob, bool(flags), limit)
940
+ if hit is not None:
941
+ return hit
942
+ hits: list[str] = []
943
+ files: list[Path] = []
944
+ if root.is_file():
945
+ files = [root]
946
+ else:
947
+ for dirpath, dirnames, filenames in os.walk(root):
948
+ dirnames[:] = [d for d in dirnames if d not in _SKIP_DIR and not d.startswith(".")]
949
+ for name in filenames:
950
+ if name.startswith("."):
951
+ continue
952
+ path = Path(dirpath) / name
953
+ if glob and not path.match(glob) and not path.name.endswith(glob.lstrip("*")):
954
+ if glob.startswith("*") and path.name.endswith(glob[1:]):
955
+ pass
956
+ else:
957
+ continue
958
+ files.append(path)
959
+ for path in files:
960
+ try:
961
+ text = path.read_text(encoding="utf-8", errors="replace")
962
+ except OSError:
963
+ continue
964
+ rel = _relpath(path)
965
+ for i, line in enumerate(text.splitlines(), start=1):
966
+ if rx.search(line):
967
+ hits.append(f"{rel}:{i}:{line}")
968
+ if len(hits) >= limit:
969
+ return "\n".join(hits)
970
+ return "\n".join(hits) or "无匹配"
971
+
972
+
973
+ def _run_cmd(args: dict) -> str:
974
+ cmd = str(args.get("command") or "").strip()
975
+ if not cmd:
976
+ return "缺少 command"
977
+ cwd = args.get("working_directory") or args.get("cwd")
978
+ work = _safe_path(str(cwd)) if cwd else ROOT
979
+ timeout = int(args.get("timeout") or 120)
980
+ try:
981
+ proc = subprocess.run(
982
+ cmd,
983
+ shell=True,
984
+ cwd=str(work),
985
+ capture_output=True,
986
+ text=True,
987
+ timeout=timeout,
988
+ encoding="utf-8",
989
+ errors="replace",
990
+ )
991
+ except subprocess.TimeoutExpired:
992
+ return f"超时 ({timeout}s)"
993
+ out = (proc.stdout or "") + (proc.stderr or "")
994
+ code = proc.returncode
995
+ return _clip(f"exit {code}\n{out}".rstrip() or f"exit {code}")
996
+
997
+
998
+ _RUNNING_PROCS: set = set()
999
+
1000
+
1001
+ def _kill_proc_sync(proc) -> None:
1002
+ """杀终端子进程;Windows 用 taskkill 连子进程树一起,失败退回 kill()。"""
1003
+ if sys.platform == "win32":
1004
+ try:
1005
+ subprocess.run(
1006
+ ["taskkill", "/PID", str(proc.pid), "/T", "/F"],
1007
+ capture_output=True,
1008
+ timeout=8,
1009
+ )
1010
+ return
1011
+ except Exception:
1012
+ pass
1013
+ try:
1014
+ proc.kill()
1015
+ except ProcessLookupError:
1016
+ pass
1017
+
1018
+
1019
+ async def _kill_proc(proc) -> None:
1020
+ _kill_proc_sync(proc)
1021
+ try:
1022
+ await proc.wait()
1023
+ except Exception:
1024
+ pass
1025
+
1026
+
1027
+ def terminate_running_tools() -> None:
1028
+ """打断时强杀还在跑的终端命令(连同子进程,尽力而为)。"""
1029
+ for proc in list(_RUNNING_PROCS):
1030
+ _RUNNING_PROCS.discard(proc)
1031
+ _kill_proc_sync(proc)
1032
+
1033
+
1034
+ async def _run_cmd_async(args: dict, on_output=None) -> str:
1035
+ """终端异步版:边跑边把输出推给界面;子进程起不来就退回同步线程池。"""
1036
+ cmd = str(args.get("command") or "").strip()
1037
+ if not cmd:
1038
+ return "缺少 command"
1039
+ cwd = args.get("working_directory") or args.get("cwd")
1040
+ work = _safe_path(str(cwd)) if cwd else ROOT
1041
+ timeout = int(args.get("timeout") or 120)
1042
+ try:
1043
+ proc = await asyncio.create_subprocess_shell(
1044
+ cmd,
1045
+ cwd=str(work),
1046
+ stdout=asyncio.subprocess.PIPE,
1047
+ stderr=asyncio.subprocess.STDOUT,
1048
+ )
1049
+ except (NotImplementedError, OSError):
1050
+ return await asyncio.to_thread(_run_cmd, args)
1051
+ _RUNNING_PROCS.add(proc)
1052
+ lines: list[str] = []
1053
+
1054
+ async def _pump() -> None:
1055
+ while True:
1056
+ raw = await proc.stdout.readline()
1057
+ if not raw:
1058
+ break
1059
+ text = raw.decode("utf-8", errors="replace").rstrip("\r\n")
1060
+ lines.append(text)
1061
+ if on_output is not None:
1062
+ try:
1063
+ on_output("\n".join(lines[-300:]))
1064
+ except Exception:
1065
+ pass
1066
+
1067
+ try:
1068
+ await asyncio.wait_for(_pump(), timeout=timeout)
1069
+ except asyncio.TimeoutError:
1070
+ await _kill_proc(proc)
1071
+ return _clip(f"超时 ({timeout}s)\n" + "\n".join(lines[-200:]))
1072
+ finally:
1073
+ _RUNNING_PROCS.discard(proc)
1074
+ code = await proc.wait()
1075
+ out = "\n".join(lines)
1076
+ return _clip(f"exit {code}\n{out}".rstrip() or f"exit {code}")
1077
+
1078
+
1079
+ def _todo_write(args: dict) -> str:
1080
+ global _TODOS
1081
+ incoming = args.get("todos")
1082
+ if not isinstance(incoming, list):
1083
+ return "todos 必须是数组"
1084
+ by_id = {str(t.get("id")): t for t in _TODOS if isinstance(t, dict)}
1085
+ for item in incoming:
1086
+ if not isinstance(item, dict):
1087
+ continue
1088
+ tid = str(item.get("id") or "")
1089
+ if not tid:
1090
+ continue
1091
+ prev = by_id.get(tid, {})
1092
+ prev.update({k: v for k, v in item.items() if v is not None})
1093
+ by_id[tid] = prev
1094
+ _TODOS = list(by_id.values())
1095
+ hook = _todo_change_hook
1096
+ if hook is not None:
1097
+ try:
1098
+ hook(_TODOS)
1099
+ except Exception:
1100
+ pass
1101
+ lines = []
1102
+ for t in _TODOS:
1103
+ lines.append(f"- [{t.get('status') or 'pending'}] {t.get('id')}: {t.get('content') or ''}")
1104
+ return "\n".join(lines) or "(空清单)"
1105
+
1106
+
1107
+ async def _web_search(args: dict) -> str:
1108
+ query = str(args.get("query") or "").strip()
1109
+ if not query:
1110
+ return "缺少 query"
1111
+ n = int(args.get("num_results") or 5)
1112
+ url = "https://html.duckduckgo.com/html/"
1113
+ timeout = aiohttp.ClientTimeout(total=20)
1114
+ async with aiohttp.ClientSession(timeout=timeout) as session:
1115
+ async with session.post(url, data={"q": query, "kl": "wt-wt"}) as resp:
1116
+ html = await resp.text()
1117
+ titles = re.findall(r'class="result__a"[^>]*>(.*?)</a>', html, flags=re.I | re.S)
1118
+ links = re.findall(r'class="result__url"[^>]*href="([^"]+)"', html, flags=re.I)
1119
+ if not links:
1120
+ links = re.findall(r'uddg=([^"&]+)', html)
1121
+ lines = []
1122
+ for i, title in enumerate(titles[:n]):
1123
+ clean = re.sub("<[^>]+>", "", title)
1124
+ href = links[i] if i < len(links) else ""
1125
+ lines.append(f"{i + 1}. {clean.strip()}\n {href}")
1126
+ return "\n".join(lines) or "没有搜到结果"
1127
+
1128
+
1129
+ async def _web_fetch(args: dict) -> str:
1130
+ url = str(args.get("url") or "").strip()
1131
+ if not url:
1132
+ return "缺少 url"
1133
+ if url.startswith("http://"):
1134
+ url = "https://" + url[len("http://") :]
1135
+ if not url.startswith("https://"):
1136
+ return "只支持 https URL"
1137
+ timeout = aiohttp.ClientTimeout(total=30)
1138
+ async with aiohttp.ClientSession(timeout=timeout) as session:
1139
+ async with session.get(url) as resp:
1140
+ status = resp.status
1141
+ text = await resp.text()
1142
+ text = re.sub(r"(?is)<script.*?>.*?</script>", " ", text)
1143
+ text = re.sub(r"(?is)<style.*?>.*?</style>", " ", text)
1144
+ text = re.sub(r"<[^>]+>", " ", text)
1145
+ text = re.sub(r"\s+", " ", text)
1146
+ body = text.strip() or f"HTTP {status}"
1147
+ return _clip(body)