jarvis-ai-assistant 0.3.30__py3-none-any.whl → 0.3.32__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.
@@ -1,6 +1,8 @@
1
1
  # -*- coding: utf-8 -*-
2
2
  """Jarvis AI 助手主入口模块"""
3
3
  from typing import Optional, List
4
+ import shutil
5
+ from datetime import datetime
4
6
 
5
7
  import typer
6
8
 
@@ -16,6 +18,7 @@ from jarvis.jarvis_utils.config import (
16
18
  get_agent_definition_dirs,
17
19
  get_multi_agent_dirs,
18
20
  get_roles_dirs,
21
+ get_data_dir,
19
22
  )
20
23
  import jarvis.jarvis_utils.utils as jutils
21
24
  from jarvis.jarvis_utils.input import user_confirm, get_single_line_input
@@ -184,6 +187,72 @@ def handle_interactive_config_option(
184
187
  return True
185
188
 
186
189
 
190
+ def handle_backup_option(backup: bool) -> bool:
191
+ """处理数据备份选项,返回是否已处理并需提前结束。"""
192
+ if not backup:
193
+ return False
194
+
195
+ init_env("", config_file=None)
196
+ data_dir = Path(get_data_dir())
197
+ if not data_dir.is_dir():
198
+ PrettyOutput.print(f"数据目录不存在: {data_dir}", OutputType.ERROR)
199
+ return True
200
+
201
+ backup_dir = Path(os.path.expanduser("~/jarvis_backups"))
202
+ backup_dir.mkdir(exist_ok=True)
203
+
204
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
205
+ backup_file_base = backup_dir / f"jarvis_data_{timestamp}"
206
+
207
+ try:
208
+ archive_path = shutil.make_archive(
209
+ str(backup_file_base), "zip", root_dir=str(data_dir)
210
+ )
211
+ PrettyOutput.print(f"数据已成功备份到: {archive_path}", OutputType.SUCCESS)
212
+ except Exception as e:
213
+ PrettyOutput.print(f"数据备份失败: {e}", OutputType.ERROR)
214
+
215
+ return True
216
+
217
+
218
+ def handle_restore_option(restore_path: Optional[str]) -> bool:
219
+ """处理数据恢复选项,返回是否已处理并需提前结束。"""
220
+ if not restore_path:
221
+ return False
222
+
223
+ restore_file = Path(restore_path)
224
+ if not restore_file.is_file():
225
+ PrettyOutput.print(f"指定的恢复文件不存在: {restore_path}", OutputType.ERROR)
226
+ return True
227
+
228
+ init_env("", config_file=None)
229
+ data_dir = Path(get_data_dir())
230
+
231
+ if data_dir.exists():
232
+ if not user_confirm(
233
+ f"数据目录 '{data_dir}' 已存在,恢复操作将覆盖它。是否继续?", default=False
234
+ ):
235
+ PrettyOutput.print("恢复操作已取消。", OutputType.INFO)
236
+ return True
237
+ try:
238
+ shutil.rmtree(data_dir)
239
+ except Exception as e:
240
+ PrettyOutput.print(f"无法移除现有数据目录: {e}", OutputType.ERROR)
241
+ return True
242
+
243
+ try:
244
+ data_dir.mkdir(parents=True)
245
+ shutil.unpack_archive(str(restore_file), str(data_dir), "zip")
246
+ PrettyOutput.print(
247
+ f"数据已从 '{restore_path}' 成功恢复到 '{data_dir}'", OutputType.SUCCESS
248
+ )
249
+
250
+ except Exception as e:
251
+ PrettyOutput.print(f"数据恢复失败: {e}", OutputType.ERROR)
252
+
253
+ return True
254
+
255
+
187
256
  def preload_config_for_flags(config_file: Optional[str]) -> None:
188
257
  """预加载配置(仅用于读取功能开关),不会显示欢迎信息或影响后续 init_env。"""
189
258
  try:
@@ -521,6 +590,12 @@ def run_cli(
521
590
  "--disable-methodology-analysis",
522
591
  help="禁用方法论和任务分析(覆盖配置文件设置)",
523
592
  ),
593
+ backup_data: bool = typer.Option(
594
+ False, "--backup-data", help="备份 Jarvis 数据目录 (~/.jarvis)"
595
+ ),
596
+ restore_data: Optional[str] = typer.Option(
597
+ None, "--restore-data", help="从指定的压缩包恢复 Jarvis 数据"
598
+ ),
524
599
  ) -> None:
525
600
  """Jarvis AI assistant command-line interface."""
526
601
  if ctx.invoked_subcommand is not None:
@@ -529,6 +604,14 @@ def run_cli(
529
604
  # 使用 rich 输出命令与快捷方式总览
530
605
  print_commands_overview()
531
606
 
607
+ # 处理数据备份
608
+ if handle_backup_option(backup_data):
609
+ return
610
+
611
+ # 处理数据恢复
612
+ if handle_restore_option(restore_data):
613
+ return
614
+
532
615
  # 处理配置文件编辑
533
616
  if handle_edit_option(edit, config_file):
534
617
  return
@@ -529,8 +529,9 @@ class CodeAgent:
529
529
  if self.agent.force_save_memory:
530
530
  self.agent.memory_manager.prompt_memory_save()
531
531
  elif start_commit:
532
- os.system(f"git reset --hard {str(start_commit)}") # 确保转换为字符串
533
- PrettyOutput.print("已重置到初始提交", OutputType.INFO)
532
+ if user_confirm("是否要重置到初始提交?", True):
533
+ os.system(f"git reset --hard {str(start_commit)}") # 确保转换为字符串
534
+ PrettyOutput.print("已重置到初始提交", OutputType.INFO)
534
535
 
535
536
  def run(self, user_input: str, prefix: str = "", suffix: str = "") -> Optional[str]:
536
537
  """使用给定的用户输入运行代码代理。
@@ -603,10 +604,92 @@ class CodeAgent:
603
604
  """工具调用后回调函数。"""
604
605
  final_ret = ""
605
606
  diff = get_diff()
607
+
608
+ # 构造按文件的状态映射与差异文本,删除文件不展示diff,仅提示删除
609
+ def _build_name_status_map() -> dict:
610
+ status_map = {}
611
+ try:
612
+ head_exists = bool(get_latest_commit_hash())
613
+ # 临时 -N 以包含未跟踪文件的差异检测
614
+ subprocess.run(["git", "add", "-N", "."], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
615
+ cmd = ["git", "diff", "--name-status"] + (["HEAD"] if head_exists else [])
616
+ res = subprocess.run(
617
+ cmd,
618
+ capture_output=True,
619
+ text=True,
620
+ encoding="utf-8",
621
+ errors="replace",
622
+ check=False,
623
+ )
624
+ finally:
625
+ subprocess.run(["git", "reset"], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
626
+
627
+ if res.returncode == 0 and res.stdout:
628
+ for line in res.stdout.splitlines():
629
+ if not line.strip():
630
+ continue
631
+ parts = line.split("\t")
632
+ if not parts:
633
+ continue
634
+ status = parts[0]
635
+ if status.startswith("R") or status.startswith("C"):
636
+ # 重命名/复制:使用新路径作为键
637
+ if len(parts) >= 3:
638
+ old_path, new_path = parts[1], parts[2]
639
+ status_map[new_path] = status
640
+ # 也记录旧路径,便于匹配 name-only 的结果
641
+ status_map[old_path] = status
642
+ elif len(parts) >= 2:
643
+ status_map[parts[-1]] = status
644
+ else:
645
+ if len(parts) >= 2:
646
+ status_map[parts[1]] = status
647
+ return status_map
648
+
649
+ def _get_file_diff(file_path: str) -> str:
650
+ """获取单文件的diff,包含新增文件内容;失败时返回空字符串"""
651
+ head_exists = bool(get_latest_commit_hash())
652
+ try:
653
+ # 为了让未跟踪文件也能展示diff,临时 -N 该文件
654
+ subprocess.run(["git", "add", "-N", "--", file_path], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
655
+ cmd = ["git", "diff"] + (["HEAD"] if head_exists else []) + ["--", file_path]
656
+ res = subprocess.run(
657
+ cmd,
658
+ capture_output=True,
659
+ text=True,
660
+ encoding="utf-8",
661
+ errors="replace",
662
+ check=False,
663
+ )
664
+ if res.returncode == 0:
665
+ return res.stdout or ""
666
+ return ""
667
+ finally:
668
+ subprocess.run(["git", "reset", "--", file_path], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
669
+
670
+ def _build_per_file_patch_preview(modified_files: List[str]) -> str:
671
+ status_map = _build_name_status_map()
672
+ lines: List[str] = []
673
+ for f in modified_files:
674
+ status = status_map.get(f, "")
675
+ # 删除文件:不展示diff,仅提示
676
+ if (status.startswith("D")) or (not os.path.exists(f)):
677
+ lines.append(f"- {f} 文件被删除")
678
+ continue
679
+ # 其它情况:展示该文件的diff
680
+ file_diff = _get_file_diff(f)
681
+ if file_diff.strip():
682
+ lines.append(f"文件: {f}\n```diff\n{file_diff}\n```")
683
+ else:
684
+ # 当无法获取到diff(例如重命名或特殊状态),避免空输出
685
+ lines.append(f"- {f} 变更已记录(无可展示的文本差异)")
686
+ return "\n".join(lines)
687
+
606
688
  if diff:
607
689
  start_hash = get_latest_commit_hash()
608
690
  PrettyOutput.print(diff, OutputType.CODE, lang="diff")
609
691
  modified_files = get_diff_file_list()
692
+ per_file_preview = _build_per_file_patch_preview(modified_files)
610
693
  commited = handle_commit_workflow()
611
694
  if commited:
612
695
  # 统计代码行数变化
@@ -630,15 +713,14 @@ class CodeAgent:
630
713
 
631
714
  StatsManager.increment("code_modifications", group="code_agent")
632
715
 
633
-
634
716
  # 获取提交信息
635
717
  end_hash = get_latest_commit_hash()
636
718
  commits = get_commits_between(start_hash, end_hash)
637
719
 
638
- # 添加提交信息到final_ret
720
+ # 添加提交信息到final_ret(按文件展示diff;删除文件仅提示)
639
721
  if commits:
640
722
  final_ret += (
641
- f"\n\n代码已修改完成\n补丁内容:\n```diff\n{diff}\n```\n"
723
+ f"\n\n代码已修改完成\n补丁内容(按文件):\n{per_file_preview}\n"
642
724
  )
643
725
  # 修改后的提示逻辑
644
726
  lint_tools_info = "\n".join(
@@ -665,7 +747,7 @@ class CodeAgent:
665
747
  final_ret += "\n\n修改没有生效\n"
666
748
  else:
667
749
  final_ret += "\n修改被拒绝\n"
668
- final_ret += f"# 补丁预览:\n```diff\n{diff}\n```"
750
+ final_ret += f"# 补丁预览(按文件):\n{per_file_preview}"
669
751
  else:
670
752
  return
671
753
  # 用户确认最终结果
@@ -7,6 +7,7 @@
7
7
  "JARVIS_MCP": {
8
8
  "type": "array",
9
9
  "description": "MCP工具配置列表",
10
+ "default": [],
10
11
  "items": {
11
12
  "type": "object",
12
13
  "oneOf": [
@@ -182,11 +183,22 @@
182
183
  "description": "执行工具前是否需要确认",
183
184
  "default": false
184
185
  },
186
+ "JARVIS_TOOL_FILTER_THRESHOLD": {
187
+ "type": "number",
188
+ "description": "AI工具筛选阈值:当可用工具数量超过此值时触发AI筛选",
189
+ "default": 30
190
+ },
185
191
  "JARVIS_CONFIRM_BEFORE_APPLY_PATCH": {
186
192
  "type": "boolean",
187
193
  "description": "应用补丁前是否需要确认",
188
194
  "default": false
189
195
  },
196
+ "JARVIS_PATCH_FORMAT": {
197
+ "type": "string",
198
+ "enum": ["all", "search", "search_range"],
199
+ "description": "补丁格式处理模式:all 同时支持 SEARCH 与 SEARCH_START/SEARCH_END;search 仅允许精确片段匹配;search_range 仅允许范围匹配。",
200
+ "default": "all"
201
+ },
190
202
  "JARVIS_DATA_PATH": {
191
203
  "type": "string",
192
204
  "description": "Jarvis数据存储目录路径",
@@ -195,7 +207,7 @@
195
207
  "JARVIS_PRETTY_OUTPUT": {
196
208
  "type": "boolean",
197
209
  "description": "是否启用美化输出",
198
- "default": false
210
+ "default": true
199
211
  },
200
212
  "JARVIS_USE_METHODOLOGY": {
201
213
  "type": "boolean",
@@ -270,6 +282,11 @@
270
282
  "description": "是否打印提示",
271
283
  "default": false
272
284
  },
285
+ "JARVIS_PRINT_ERROR_TRACEBACK": {
286
+ "type": "boolean",
287
+ "description": "是否在错误输出时打印回溯调用链",
288
+ "default": false
289
+ },
273
290
  "JARVIS_ENABLE_STATIC_ANALYSIS": {
274
291
  "type": "boolean",
275
292
  "description": "是否启用静态代码分析",
@@ -278,7 +295,7 @@
278
295
  "JARVIS_FORCE_SAVE_MEMORY": {
279
296
  "type": "boolean",
280
297
  "description": "是否强制保存记忆",
281
- "default": true
298
+ "default": false
282
299
  },
283
300
  "JARVIS_ENABLE_GIT_JCA_SWITCH": {
284
301
  "type": "boolean",
@@ -341,7 +358,23 @@
341
358
  "JARVIS_RAG_GROUPS": {
342
359
  "type": "array",
343
360
  "description": "预定义的RAG配置组",
344
- "default": [],
361
+ "default": [
362
+ {
363
+ "text": {
364
+ "embedding_model": "BAAI/bge-m3",
365
+ "rerank_model": "BAAI/bge-reranker-v2-m3",
366
+ "use_bm25": true,
367
+ "use_rerank": true
368
+ }
369
+ },
370
+ {
371
+ "code": {
372
+ "embedding_model": "Qodo/Qodo-Embed-1-1.5B",
373
+ "use_bm25": false,
374
+ "use_rerank": false
375
+ }
376
+ }
377
+ ],
345
378
  "items": {
346
379
  "type": "object",
347
380
  "additionalProperties": {
@@ -421,7 +454,8 @@
421
454
  "required": [
422
455
  "template"
423
456
  ]
424
- }
457
+ },
458
+ "default": {}
425
459
  },
426
460
  "OPENAI_API_KEY": {
427
461
  "type": "string",
@@ -24,7 +24,32 @@ class OpenAIModel(BasePlatform):
24
24
  self.base_url = os.getenv("OPENAI_API_BASE", "https://api.openai.com/v1")
25
25
  self.model_name = os.getenv("JARVIS_MODEL") or "gpt-4o"
26
26
 
27
- self.client = OpenAI(api_key=self.api_key, base_url=self.base_url)
27
+ # Optional: Inject extra HTTP headers via environment variable
28
+ # Expected format: OPENAI_EXTRA_HEADERS='{"Header-Name": "value", "X-Trace": "abc"}'
29
+ headers_str = os.getenv("OPENAI_EXTRA_HEADERS")
30
+ self.extra_headers: Dict[str, str] = {}
31
+ if headers_str:
32
+ try:
33
+ parsed = json.loads(headers_str)
34
+ if isinstance(parsed, dict):
35
+ # Ensure all header keys/values are strings
36
+ self.extra_headers = {str(k): str(v) for k, v in parsed.items()}
37
+ else:
38
+ PrettyOutput.print("OPENAI_EXTRA_HEADERS 应为 JSON 对象,如 {'X-Source':'jarvis'}", OutputType.WARNING)
39
+ except Exception as e:
40
+ PrettyOutput.print(f"解析 OPENAI_EXTRA_HEADERS 失败: {e}", OutputType.WARNING)
41
+
42
+ # Initialize OpenAI client, try to pass default headers if SDK supports it
43
+ try:
44
+ if self.extra_headers:
45
+ self.client = OpenAI(api_key=self.api_key, base_url=self.base_url, default_headers=self.extra_headers)
46
+ else:
47
+ self.client = OpenAI(api_key=self.api_key, base_url=self.base_url)
48
+ except TypeError:
49
+ # Fallback: SDK version may not support default_headers
50
+ self.client = OpenAI(api_key=self.api_key, base_url=self.base_url)
51
+ if self.extra_headers:
52
+ PrettyOutput.print("当前 OpenAI SDK 不支持 default_headers,未能注入额外 HTTP 头", OutputType.WARNING)
28
53
  self.messages: List[Dict[str, str]] = []
29
54
  self.system_message = ""
30
55
 
@@ -27,15 +27,25 @@ class FileSearchReplaceTool:
27
27
 
28
28
  ## 基本使用
29
29
  1. 指定需要修改的文件路径(单个或多个)
30
- 2. 提供一组或多组修改,每个修改包含:
31
- - reason: 修改原因描述
32
- - SEARCH: 需要查找的原始代码(必须包含足够上下文)
33
- - REPLACE: 替换后的新代码
30
+ 2. 提供一组或多组修改,每个修改支持两种格式:
31
+ - 单点替换:
32
+ - reason: 修改原因描述
33
+ - SEARCH: 需要查找的原始代码(必须包含足够上下文)
34
+ - REPLACE: 替换后的新代码
35
+ - 区间替换:
36
+ - reason: 修改原因描述
37
+ - SEARCH_START: 起始标记
38
+ - SEARCH_END: 结束标记
39
+ - REPLACE: 替换后的新代码
40
+ - RANGE: 可选的行号范围 'start-end' (1-based, 闭区间), 用于限定匹配范围
41
+ - **说明**: 区间替换会从包含 SEARCH_START 的行首开始,到包含 SEARCH_END 的行尾结束,替换整个区域
34
42
 
35
43
  ## 核心原则
36
44
  1. **精准修改**: 只修改必要的代码部分,保持其他部分不变
37
45
  2. **最小补丁原则**: 生成最小范围的补丁,包含必要的上下文
38
- 3. **唯一匹配**: 确保搜索文本在文件中唯一匹配
46
+ 3. **唯一匹配**:
47
+ - 单点替换:确保 SEARCH 在文件中唯一匹配
48
+ - 区间替换:确保在指定范围内,第一个 SEARCH_START 之后能找到 SEARCH_END
39
49
  4. **格式保持**: 严格保持原始代码的格式风格
40
50
  5. **部分成功**: 支持多个文件编辑,允许部分文件编辑成功
41
51
 
@@ -62,12 +72,24 @@ class FileSearchReplaceTool:
62
72
  },
63
73
  "SEARCH": {
64
74
  "type": "string",
65
- "description": "需要查找的原始代码",
75
+ "description": "需要查找的原始代码(单点替换模式)",
76
+ },
77
+ "SEARCH_START": {
78
+ "type": "string",
79
+ "description": "区间替换的起始标记(包含在替换范围内)",
80
+ },
81
+ "SEARCH_END": {
82
+ "type": "string",
83
+ "description": "区间替换的结束标记(包含在替换范围内)",
66
84
  },
67
85
  "REPLACE": {
68
86
  "type": "string",
69
87
  "description": "替换后的新代码",
70
88
  },
89
+ "RANGE": {
90
+ "type": "string",
91
+ "description": "行号范围 'start-end'(1-based,闭区间),可选,仅用于区间替换模式,用于限定匹配与替换的行号范围",
92
+ },
71
93
  },
72
94
  },
73
95
  },
@@ -93,10 +115,17 @@ class FileSearchReplaceTool:
93
115
  args: 包含以下键的字典:
94
116
  - files: 文件列表,每个文件包含(必填):
95
117
  - path: 要修改的文件路径
96
- - changes: 修改列表,每个修改包含:
97
- - reason: 修改原因描述
98
- - SEARCH: 需要查找的原始代码(必须包含足够上下文)
99
- - REPLACE: 替换后的新代码
118
+ - changes: 修改列表,每个修改支持两种格式:
119
+ 1) 单点替换:
120
+ - reason: 修改原因描述
121
+ - SEARCH: 需要查找的原始代码(必须包含足够上下文)
122
+ - REPLACE: 替换后的新代码
123
+ 2) 区间替换:
124
+ - reason: 修改原因描述
125
+ - SEARCH_START: 起始标记
126
+ - SEARCH_END: 结束标记
127
+ - REPLACE: 替换后的新代码
128
+ - **说明**: 区间替换会从包含 SEARCH_START 的行首开始,到包含 SEARCH_END 的行尾结束,替换整个区域
100
129
 
101
130
  返回:
102
131
  Dict[str, Any] 包含:
@@ -62,10 +62,9 @@ class ReadCodeTool:
62
62
  }
63
63
 
64
64
  # 读取文件内容
65
+ # 第一遍流式读取,仅统计总行数,避免一次性读入内存
65
66
  with open(abs_path, "r", encoding="utf-8", errors="ignore") as f:
66
- lines = f.readlines()
67
-
68
- total_lines = len(lines)
67
+ total_lines = sum(1 for _ in f)
69
68
 
70
69
  # 处理空文件情况
71
70
  if total_lines == 0:
@@ -99,14 +98,16 @@ class ReadCodeTool:
99
98
  "stderr": f"无效的行范围 [{start_line}-{end_line}] (总行数: {total_lines})",
100
99
  }
101
100
 
102
- # 添加行号并构建输出内容
103
- selected_lines = lines[start_line - 1 : end_line]
104
- numbered_content = "".join(
105
- [
106
- f"{i:4d}:{line}"
107
- for i, line in enumerate(selected_lines, start=start_line)
108
- ]
109
- )
101
+ # 添加行号并构建输出内容(第二遍流式读取,仅提取范围行)
102
+ selected_items = []
103
+ with open(abs_path, "r", encoding="utf-8", errors="ignore") as f:
104
+ for i, line in enumerate(f, start=1):
105
+ if i < start_line:
106
+ continue
107
+ if i > end_line:
108
+ break
109
+ selected_items.append((i, line))
110
+ numbered_content = "".join(f"{i:4d}:{line}" for i, line in selected_items)
110
111
 
111
112
  # 构建输出格式
112
113
  output = (
@@ -51,6 +51,7 @@ arguments:
51
51
  - 完全按照上述格式
52
52
  - 使用正确的YAML格式,2个空格作为缩进
53
53
  - 包含所有必需参数
54
+ - {ot("TOOL_CALL")} 和 {ct("TOOL_CALL")} 必须出现在行首
54
55
  </rule>
55
56
 
56
57
  <rule>
@@ -101,6 +102,7 @@ arguments:
101
102
  - 创建虚构对话
102
103
  - 在没有所需信息的情况下继续
103
104
  - yaml 格式错误
105
+ - {ot("TOOL_CALL")} 和 {ct("TOOL_CALL")} 没有出现在行首
104
106
  </common_errors>
105
107
  </tool_system_guide>
106
108
  """
@@ -121,7 +123,8 @@ class ToolRegistry(OutputHandlerProtocol):
121
123
  return "TOOL_CALL"
122
124
 
123
125
  def can_handle(self, response: str) -> bool:
124
- return ot("TOOL_CALL") in response
126
+ # 仅当 {ot("TOOL_CALL")} 出现在行首时才认为可以处理
127
+ return re.search(rf'(?m){re.escape(ot("TOOL_CALL"))}', response) is not None
125
128
 
126
129
  def prompt(self) -> str:
127
130
  """加载工具"""
@@ -608,11 +611,9 @@ class ToolRegistry(OutputHandlerProtocol):
608
611
 
609
612
  @staticmethod
610
613
  def _has_tool_calls_block(content: str) -> bool:
611
- """从内容中提取工具调用块"""
612
- return (
613
- re.search(ot("TOOL_CALL") + r"(.*?)" + ct("TOOL_CALL"), content, re.DOTALL)
614
- is not None
615
- )
614
+ """从内容中提取工具调用块(仅匹配行首标签)"""
615
+ pattern = rf'(?ms){re.escape(ot("TOOL_CALL"))}(.*?)^{re.escape(ct("TOOL_CALL"))}'
616
+ return re.search(pattern, content) is not None
616
617
 
617
618
  @staticmethod
618
619
  def _extract_tool_calls(
@@ -633,22 +634,22 @@ class ToolRegistry(OutputHandlerProtocol):
633
634
  Exception: 如果工具调用缺少必要字段
634
635
  """
635
636
  # 将内容拆分为行
636
- data = re.findall(
637
- ot("TOOL_CALL") + r"(.*?)" + ct("TOOL_CALL"), content, re.DOTALL
638
- )
637
+ pattern = rf'(?ms){re.escape(ot("TOOL_CALL"))}(.*?)^{re.escape(ct("TOOL_CALL"))}'
638
+ data = re.findall(pattern, content)
639
639
  auto_completed = False
640
640
  if not data:
641
- # can_handle 确保 ot("TOOL_CALL") 在内容中。
642
- # 如果数据为空,则表示 ct("TOOL_CALL") 可能丢失。
643
- if ot("TOOL_CALL") in content and ct("TOOL_CALL") not in content:
644
- # 尝试通过附加结束标签来修复它
641
+ # can_handle 确保 ot("TOOL_CALL") 在内容中(行首)。
642
+ # 如果数据为空,则表示行首的 ct("TOOL_CALL") 可能丢失。
643
+ has_open_at_bol = re.search(rf'(?m){re.escape(ot("TOOL_CALL"))}', content) is not None
644
+ has_close_at_bol = re.search(rf'(?m)^{re.escape(ct("TOOL_CALL"))}', content) is not None
645
+ if has_open_at_bol and not has_close_at_bol:
646
+ # 尝试通过附加结束标签来修复它(确保结束标签位于行首)
645
647
  fixed_content = content.strip() + f"\n{ct('TOOL_CALL')}"
646
648
 
647
649
  # 再次提取,并检查YAML是否有效
648
650
  temp_data = re.findall(
649
- ot("TOOL_CALL") + r"(.*?)" + ct("TOOL_CALL"),
651
+ pattern,
650
652
  fixed_content,
651
- re.DOTALL,
652
653
  )
653
654
 
654
655
  if temp_data:
@@ -248,6 +248,23 @@ def is_confirm_before_apply_patch() -> bool:
248
248
  return GLOBAL_CONFIG_DATA.get("JARVIS_CONFIRM_BEFORE_APPLY_PATCH", False)
249
249
 
250
250
 
251
+ def get_patch_format() -> str:
252
+ """
253
+ 获取补丁格式。
254
+
255
+ - "search": 仅使用精确匹配的 `SEARCH` 模式。此模式对能力较弱的模型更稳定,因为它要求代码片段完全匹配。
256
+ - "search_range": 仅使用 `SEARCH_START` 和 `SEARCH_END` 的范围匹配模式。此模式对能力较强的模型更灵活,因为它允许在代码块内部进行修改,而不要求整个块完全匹配。
257
+ - "all": 同时支持以上两种模式(默认)。
258
+
259
+ 返回:
260
+ str: "all", "search", or "search_range"
261
+ """
262
+ mode = GLOBAL_CONFIG_DATA.get("JARVIS_PATCH_FORMAT", "all")
263
+ if mode in ["all", "search", "search_range"]:
264
+ return mode
265
+ return "all"
266
+
267
+
251
268
  def get_data_dir() -> str:
252
269
  """
253
270
  获取Jarvis数据存储目录路径。
@@ -21,6 +21,9 @@ def get_context_token_count(text: str) -> int:
21
21
  返回:
22
22
  int: 文本中的token数量
23
23
  """
24
+ # 防御性检查:入参为 None 或空字符串时直接返回 0
25
+ if text is None or text == "":
26
+ return 0
24
27
  try:
25
28
  import tiktoken
26
29
 
@@ -34,7 +34,13 @@ def find_git_root_and_cd(start_dir: str = ".") -> str:
34
34
  """
35
35
  os.chdir(start_dir)
36
36
  try:
37
- git_root = os.popen("git rev-parse --show-toplevel").read().strip()
37
+ result = subprocess.run(
38
+ ["git", "rev-parse", "--show-toplevel"],
39
+ capture_output=True,
40
+ text=True,
41
+ check=True,
42
+ )
43
+ git_root = result.stdout.strip()
38
44
  if not git_root:
39
45
  subprocess.run(["git", "init"], check=True)
40
46
  git_root = os.path.abspath(".")
@@ -291,7 +297,13 @@ def get_modified_line_ranges() -> Dict[str, List[Tuple[int, int]]]:
291
297
  行号从1开始。
292
298
  """
293
299
  # 获取所有文件的Git差异
294
- diff_output = os.popen("git show").read()
300
+ # 仅用于解析修改行范围,减少上下文以降低输出体积和解析成本
301
+ result = subprocess.run(
302
+ ["git", "show", "--no-color"],
303
+ capture_output=True,
304
+ text=True,
305
+ )
306
+ diff_output = result.stdout
295
307
 
296
308
  # 解析差异以获取修改的文件及其行范围
297
309
  result: Dict[str, List[Tuple[int, int]]] = {}
@@ -482,7 +482,7 @@ def _get_multiline_input_internal(
482
482
  """Handle Ctrl+O by exiting the prompt and returning the sentinel value."""
483
483
  event.app.exit(result=CTRL_O_SENTINEL)
484
484
 
485
- @bindings.add("c-t", filter=has_focus(DEFAULT_BUFFER))
485
+ @bindings.add("c-t", filter=has_focus(DEFAULT_BUFFER), eager=True)
486
486
  def _(event):
487
487
  """Return a shell command like '!bash' for upper input_handler to execute."""
488
488