bone-agent 1.4.0 → 2.0.1

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.
Files changed (126) hide show
  1. package/bin/bone.js +39 -0
  2. package/package.json +25 -39
  3. package/LICENSE +0 -21
  4. package/README.md +0 -201
  5. package/bin/npm-wrapper.js +0 -235
  6. package/bin/rg +0 -0
  7. package/bin/rg.exe +0 -0
  8. package/config.yaml.example +0 -144
  9. package/prompts/main/ask_questions.md +0 -31
  10. package/prompts/main/batch_independent_calls.md +0 -5
  11. package/prompts/main/casual_interactions.md +0 -11
  12. package/prompts/main/code_references.md +0 -8
  13. package/prompts/main/communication_style.md +0 -12
  14. package/prompts/main/context_reliability.md +0 -12
  15. package/prompts/main/conversational_tool_calling.md +0 -15
  16. package/prompts/main/dream.md +0 -50
  17. package/prompts/main/editing_pattern.md +0 -13
  18. package/prompts/main/error_handling.md +0 -6
  19. package/prompts/main/exploration_pattern.md +0 -21
  20. package/prompts/main/intro.md +0 -1
  21. package/prompts/main/obsidian.md +0 -16
  22. package/prompts/main/obsidian_project.md +0 -79
  23. package/prompts/main/professional_objectivity.md +0 -3
  24. package/prompts/main/skills.md +0 -3
  25. package/prompts/main/targeted_searching.md +0 -10
  26. package/prompts/main/task_lists_pattern.md +0 -8
  27. package/prompts/main/temp_folder.md +0 -9
  28. package/prompts/main/think_before_acting.md +0 -10
  29. package/prompts/main/tone_and_style.md +0 -4
  30. package/prompts/main/tool_preferences.md +0 -24
  31. package/prompts/main/trust_subagent_context.md +0 -21
  32. package/prompts/main/when_to_use_sub_agent.md +0 -7
  33. package/prompts/micro/ask_questions.md +0 -1
  34. package/prompts/micro/batch_independent_calls.md +0 -1
  35. package/prompts/micro/casual_interactions.md +0 -1
  36. package/prompts/micro/code_references.md +0 -1
  37. package/prompts/micro/communication_style.md +0 -1
  38. package/prompts/micro/context_reliability.md +0 -1
  39. package/prompts/micro/conversational_tool_calling.md +0 -1
  40. package/prompts/micro/editing_pattern.md +0 -1
  41. package/prompts/micro/error_handling.md +0 -1
  42. package/prompts/micro/exploration_pattern.md +0 -1
  43. package/prompts/micro/intro.md +0 -1
  44. package/prompts/micro/obsidian.md +0 -4
  45. package/prompts/micro/obsidian_project.md +0 -5
  46. package/prompts/micro/professional_objectivity.md +0 -1
  47. package/prompts/micro/skills.md +0 -1
  48. package/prompts/micro/targeted_searching.md +0 -1
  49. package/prompts/micro/task_lists_pattern.md +0 -1
  50. package/prompts/micro/temp_folder.md +0 -1
  51. package/prompts/micro/think_before_acting.md +0 -5
  52. package/prompts/micro/tone_and_style.md +0 -1
  53. package/prompts/micro/tool_preferences.md +0 -1
  54. package/prompts/micro/trust_subagent_context.md +0 -1
  55. package/prompts/micro/when_to_use_sub_agent.md +0 -1
  56. package/requirements.txt +0 -9
  57. package/src/__init__.py +0 -11
  58. package/src/core/__init__.py +0 -1
  59. package/src/core/agentic.py +0 -1085
  60. package/src/core/chat_manager.py +0 -1577
  61. package/src/core/config_manager.py +0 -260
  62. package/src/core/cron.py +0 -578
  63. package/src/core/cron_allowlist.py +0 -118
  64. package/src/core/memory.py +0 -145
  65. package/src/core/metadata.py +0 -75
  66. package/src/core/retry.py +0 -71
  67. package/src/core/skills.py +0 -463
  68. package/src/core/sub_agent.py +0 -376
  69. package/src/core/tool_approval.py +0 -220
  70. package/src/core/tool_feedback.py +0 -789
  71. package/src/exceptions.py +0 -79
  72. package/src/llm/__init__.py +0 -1
  73. package/src/llm/client.py +0 -176
  74. package/src/llm/codex_provider.py +0 -350
  75. package/src/llm/config.py +0 -536
  76. package/src/llm/prompts.py +0 -494
  77. package/src/llm/providers.py +0 -438
  78. package/src/llm/streaming.py +0 -163
  79. package/src/llm/token_tracker.py +0 -399
  80. package/src/tools/__init__.py +0 -151
  81. package/src/tools/constants.py +0 -59
  82. package/src/tools/create_file.py +0 -136
  83. package/src/tools/directory.py +0 -389
  84. package/src/tools/edit.py +0 -549
  85. package/src/tools/file_reader.py +0 -322
  86. package/src/tools/helpers/__init__.py +0 -99
  87. package/src/tools/helpers/base.py +0 -599
  88. package/src/tools/helpers/converters.py +0 -44
  89. package/src/tools/helpers/file_helpers.py +0 -189
  90. package/src/tools/helpers/formatters.py +0 -411
  91. package/src/tools/helpers/loader.py +0 -145
  92. package/src/tools/helpers/parallel_executor.py +0 -231
  93. package/src/tools/helpers/path_resolver.py +0 -283
  94. package/src/tools/helpers/plugin_manifest.py +0 -185
  95. package/src/tools/obsidian.py +0 -96
  96. package/src/tools/review_sub_agent.py +0 -190
  97. package/src/tools/rg_search.py +0 -477
  98. package/src/tools/search_plugins.py +0 -177
  99. package/src/tools/select_option.py +0 -600
  100. package/src/tools/shell.py +0 -302
  101. package/src/tools/sub_agent.py +0 -139
  102. package/src/tools/task_list.py +0 -269
  103. package/src/tools/web_search.py +0 -61
  104. package/src/ui/__init__.py +0 -1
  105. package/src/ui/banner.py +0 -87
  106. package/src/ui/commands.py +0 -3131
  107. package/src/ui/displays.py +0 -239
  108. package/src/ui/loader.py +0 -284
  109. package/src/ui/main.py +0 -643
  110. package/src/ui/prompt_utils.py +0 -113
  111. package/src/ui/setting_selector.py +0 -590
  112. package/src/ui/setup_wizard.py +0 -294
  113. package/src/ui/sub_agent_panel.py +0 -234
  114. package/src/ui/tool_confirmation.py +0 -226
  115. package/src/utils/__init__.py +0 -1
  116. package/src/utils/citation_parser.py +0 -199
  117. package/src/utils/editor.py +0 -207
  118. package/src/utils/gitignore_filter.py +0 -149
  119. package/src/utils/logger.py +0 -254
  120. package/src/utils/paths.py +0 -30
  121. package/src/utils/result_parsers.py +0 -108
  122. package/src/utils/safe_commands.py +0 -243
  123. package/src/utils/settings.py +0 -195
  124. package/src/utils/user_message_logger.py +0 -120
  125. package/src/utils/validation.py +0 -201
  126. package/src/utils/web_search.py +0 -173
@@ -1,149 +0,0 @@
1
- """Centralized .gitignore filtering using pathspec library."""
2
-
3
- import logging
4
- from pathlib import Path
5
- from typing import Optional, Tuple
6
-
7
- logger = logging.getLogger(__name__)
8
-
9
- # Always allow .gitignore itself to be read/edited
10
- ALWAYS_ALLOWED_FILES = {".gitignore"}
11
-
12
- # Try to import pathspec at module level
13
- try:
14
- import pathspec
15
- except ImportError:
16
- pathspec = None
17
- logger.warning("pathspec library not installed - .gitignore filtering disabled")
18
-
19
-
20
- def load_gitignore_spec(repo_root: Path):
21
- """Load .gitignore patterns into a PathSpec object.
22
-
23
- Args:
24
- repo_root: Repository root directory
25
-
26
- Returns:
27
- pathspec.PathSpec or None if .gitignore doesn't exist
28
- """
29
- # Return None early if pathspec is not available
30
- if pathspec is None:
31
- return None
32
-
33
- gitignore_path = repo_root / ".gitignore"
34
-
35
- if not gitignore_path.exists():
36
- return None
37
-
38
- try:
39
- # Read .gitignore patterns
40
- patterns = gitignore_path.read_text(encoding="utf-8").splitlines()
41
-
42
- # Create PathSpec with gitwildmatch (git's pattern matching)
43
- spec = pathspec.PathSpec.from_lines("gitwildmatch", patterns)
44
- return spec
45
-
46
- except Exception as e:
47
- logger.warning(f"Failed to load .gitignore: {e}")
48
- return None
49
-
50
-
51
- def is_path_ignored(
52
- path: Path, repo_root: Path, gitignore_spec
53
- ) -> Tuple[bool, Optional[str]]:
54
- """Check if a path is ignored by .gitignore.
55
-
56
- Args:
57
- path: Absolute path to check
58
- repo_root: Repository root directory
59
- gitignore_spec: pathspec.PathSpec object (or None)
60
-
61
- Returns:
62
- Tuple of (is_ignored, matched_pattern)
63
- - is_ignored: True if path should be blocked
64
- - matched_pattern: The pattern that matched (or None)
65
- """
66
- # No filtering if no .gitignore
67
- if gitignore_spec is None:
68
- return False, None
69
-
70
- # Always allow .gitignore itself
71
- if path.name in ALWAYS_ALLOWED_FILES:
72
- return False, None
73
-
74
- # Get relative path from repo root (gitignore only applies within repo)
75
- try:
76
- rel_path = path.relative_to(repo_root)
77
- except ValueError:
78
- # Path is outside repo - gitignore doesn't apply
79
- return False, None
80
-
81
- # Convert to forward slashes (git convention)
82
- rel_path_str = str(rel_path).replace("\\", "/")
83
-
84
- # Check if path matches any .gitignore pattern
85
- # pathspec.match_file() returns True if the file should be ignored
86
- if gitignore_spec.match_file(rel_path_str):
87
- # Find which pattern matched (for better error messages)
88
- matched_pattern = _find_matching_pattern(rel_path_str, gitignore_spec)
89
- return True, matched_pattern
90
-
91
- return False, None
92
-
93
-
94
- def _find_matching_pattern(path_str: str, gitignore_spec) -> Optional[str]:
95
- """Find which .gitignore pattern matched a path.
96
-
97
- This is for better error messages.
98
-
99
- Args:
100
- path_str: Relative path string (with forward slashes)
101
- gitignore_spec: pathspec.PathSpec object
102
-
103
- Returns:
104
- The matching pattern string, or None
105
- """
106
- try:
107
- # PathSpec stores patterns internally
108
- for pattern in gitignore_spec.patterns:
109
- if pattern.match_file(path_str):
110
- # Return the original pattern string
111
- return pattern.pattern
112
- except Exception:
113
- pass
114
-
115
- return None
116
-
117
-
118
- def format_gitignore_error(
119
- path: Path, repo_root: Path, matched_pattern: Optional[str]
120
- ) -> str:
121
- """Format a user-friendly error message for .gitignore blocked files.
122
-
123
- Args:
124
- path: The blocked file path
125
- repo_root: Repository root
126
- matched_pattern: The .gitignore pattern that matched
127
-
128
- Returns:
129
- Formatted error message
130
- """
131
- try:
132
- rel_path = path.relative_to(repo_root)
133
- except ValueError:
134
- rel_path = path
135
-
136
- error_msg = (
137
- f"exit_code=ERROR_GITIGNORE_BLOCKED\n"
138
- f"File blocked by .gitignore: {rel_path}\n\n"
139
- )
140
- error_msg += "This file matches patterns in .gitignore and cannot be accessed.\n"
141
-
142
- if matched_pattern:
143
- error_msg += f"Matched pattern: {matched_pattern}\n"
144
-
145
- error_msg += "\nTo access this file:\n"
146
- error_msg += "1. Remove it from .gitignore, or\n"
147
- error_msg += "2. Use git commands directly (git show, git diff)\n"
148
-
149
- return error_msg
@@ -1,254 +0,0 @@
1
- """Markdown conversation logging module for saving chat history to readable markdown files."""
2
-
3
- import json
4
- import logging
5
- from datetime import datetime
6
- from pathlib import Path
7
- from typing import Optional, Dict, Any, List
8
-
9
- logger = logging.getLogger(__name__)
10
-
11
-
12
- class MarkdownConversationLogger:
13
- """Logs conversations to Markdown format with tool call details."""
14
-
15
- MAX_CONTENT_LENGTH = 2000
16
-
17
- def __init__(self, conversations_dir: str = "conversations"):
18
- """Initialize markdown conversation logger.
19
-
20
- Args:
21
- conversations_dir: Directory to save conversation logs
22
- """
23
- self.conversations_dir = Path(conversations_dir)
24
- self.conversations_dir.mkdir(exist_ok=True)
25
- self.current_file: Optional[Path] = None
26
-
27
- def start_session(self):
28
- """Start a new conversation session."""
29
- timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
30
- self.current_file = self.conversations_dir / f"conversation_{timestamp}.md"
31
- logger.info(f"Started markdown conversation logging to {self.current_file}")
32
-
33
- # Write header
34
- with open(self.current_file, 'w', encoding='utf-8') as f:
35
- f.write(f"# Conversation Log\n\n")
36
- f.write(f"**Started:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n")
37
- f.write("---\n\n")
38
-
39
- def _format_tool_call(self, tool_call: Dict[str, Any]) -> str:
40
- """Format a tool call for markdown display.
41
-
42
- Args:
43
- tool_call: Tool call dict with id, type, function
44
-
45
- Returns:
46
- Formatted markdown string
47
- """
48
- fn = tool_call.get("function", {})
49
- name = fn.get("name", "unknown")
50
- arguments = fn.get("arguments", "{}")
51
- args_str = self._format_json_value(arguments)
52
-
53
- return f"""### {name}
54
-
55
- ```json
56
- {args_str}
57
- ```"""
58
-
59
- def _format_json_value(self, value: Any) -> str:
60
- """Format a value as JSON for markdown display.
61
-
62
- Args:
63
- value: Value to format (string, dict, or other)
64
-
65
- Returns:
66
- Formatted JSON string, or string representation if not JSON-serializable
67
- """
68
- try:
69
- if isinstance(value, str):
70
- parsed = json.loads(value)
71
- else:
72
- parsed = value
73
- return json.dumps(parsed, indent=2, ensure_ascii=False)
74
- except (json.JSONDecodeError, TypeError):
75
- return str(value)
76
-
77
- def _format_tool_call_inline(self, arguments: Any) -> str:
78
- """Format tool call arguments as JSON for inline display.
79
-
80
- Args:
81
- arguments: Tool call arguments (string or dict)
82
-
83
- Returns:
84
- Formatted JSON string
85
- """
86
- args_str = self._format_json_value(arguments)
87
- return f"```json\n{args_str}\n```"
88
-
89
- def _format_tool_result(self, message: Dict[str, Any]) -> str:
90
- """Format a tool result for markdown display.
91
-
92
- Args:
93
- message: Tool result message with role, tool_call_id, content
94
-
95
- Returns:
96
- Formatted markdown string
97
- """
98
- content = message.get("content", "")
99
-
100
- # Truncate very long outputs
101
- if len(content) > self.MAX_CONTENT_LENGTH:
102
- content = content[:self.MAX_CONTENT_LENGTH] + "\n\n... (truncated)"
103
-
104
- # Try to format as code if it looks like structured output
105
- if content and (content.startswith("{") or content.startswith("[")):
106
- try:
107
- parsed = json.loads(content)
108
- content = json.dumps(parsed, indent=2, ensure_ascii=False)
109
- return f"```\n{content}\n```\n"
110
- except json.JSONDecodeError:
111
- pass
112
-
113
- return f"```\n{content}\n```\n"
114
-
115
- def _format_message(self, message: Dict[str, Any], skip_tool_calls: bool = False) -> str:
116
- """Convert a message dict to markdown format.
117
-
118
- Args:
119
- message: Message dict with role, content, tool_calls, etc.
120
- skip_tool_calls: If True, don't include tool_calls section
121
-
122
- Returns:
123
- Formatted markdown string
124
- """
125
- role = message.get("role", "unknown")
126
- content = message.get("content", "")
127
-
128
- if role == "user":
129
- emoji = "👤"
130
- title = "User"
131
- elif role == "assistant":
132
- emoji = "🤖"
133
- title = "Assistant"
134
- elif role == "tool":
135
- # Tool results are handled separately with their tool calls
136
- return None
137
- elif role == "system":
138
- emoji = "⚙️"
139
- title = "System"
140
- else:
141
- emoji = "📝"
142
- title = role.capitalize()
143
-
144
- md = f"\n## {emoji} {title}\n\n"
145
-
146
- # Add content if present
147
- if content:
148
- md += f"{content}\n\n"
149
-
150
- # Add tool calls if present (skip when skip_tool_calls=True)
151
- if not skip_tool_calls and message.get("tool_calls"):
152
- md += "### 🔧 Tool Calls\n\n"
153
- for tc in message["tool_calls"]:
154
- md += self._format_tool_call(tc) + "\n"
155
-
156
- return md
157
-
158
- def log_message(self, message: Dict[str, Any]):
159
- """Append a message to the current markdown file.
160
-
161
- Args:
162
- message: Message dict with 'role' and 'content' keys, optionally 'tool_calls'
163
- """
164
- if not self.current_file:
165
- self.start_session()
166
-
167
- # Check if this is a tool result - we'll handle it differently
168
- if message.get("role") == "tool":
169
- # Find the associated tool call by tool_call_id
170
- tool_call_id = message.get("tool_call_id")
171
- formatted = f"\n### 📋 Tool Result (ID: `{tool_call_id}`)\n\n"
172
- formatted += self._format_tool_result(message) + "\n"
173
- else:
174
- formatted = self._format_message(message)
175
-
176
- if formatted:
177
- with open(self.current_file, 'a', encoding='utf-8') as f:
178
- f.write(formatted)
179
-
180
- def rewrite_log(self, messages: List[Dict[str, Any]]):
181
- """Rewrite the current markdown log to match the provided messages.
182
-
183
- Args:
184
- messages: Full message list to persist
185
- """
186
- if not self.current_file:
187
- self.start_session()
188
-
189
- # Rewrite entire file
190
- with open(self.current_file, 'w', encoding='utf-8') as f:
191
- # Write header
192
- f.write(f"# Conversation Log\n\n")
193
- f.write(f"**Started:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n")
194
- f.write(f"**Last Updated:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n")
195
- f.write("---\n\n")
196
-
197
- # Track tool calls to pair with results
198
- pending_tool_calls = {}
199
-
200
- for message in messages:
201
- role = message.get("role")
202
-
203
- if role == "assistant" and message.get("tool_calls"):
204
- # Store tool calls for later pairing with results
205
- for tc in message["tool_calls"]:
206
- pending_tool_calls[tc["id"]] = tc
207
-
208
- # Write the assistant message (skip tool_calls section)
209
- formatted = self._format_message(message, skip_tool_calls=True)
210
- if formatted:
211
- f.write(formatted)
212
-
213
- elif role == "tool":
214
- # This is a tool result, pair it with the call
215
- tool_call_id = message.get("tool_call_id")
216
- tc = pending_tool_calls.get(tool_call_id)
217
-
218
- if tc:
219
- # Write tool call with result together
220
- fn = tc.get("function", {})
221
- name = fn.get("name", "unknown")
222
- arguments = fn.get("arguments", "{}")
223
-
224
- f.write(f"\n### 🔧 Tool Call: {name}\n\n")
225
- f.write(self._format_tool_call_inline(arguments) + "\n\n")
226
- f.write(f"**Result:**\n\n")
227
- else:
228
- # Orphaned result (no matching call)
229
- f.write(f"\n### 🔧 Tool Result (ID: `{tool_call_id}`)\n\n")
230
-
231
- f.write(self._format_tool_result(message) + "\n")
232
-
233
- else:
234
- # Regular message (user, system)
235
- formatted = self._format_message(message)
236
- if formatted:
237
- f.write(formatted)
238
-
239
- # Write footer
240
- f.write("\n---\n\n")
241
- f.write(f"*End of conversation*\n")
242
-
243
- def end_session(self):
244
- """End the current conversation logging session."""
245
- if not self.current_file:
246
- return
247
-
248
- # Add footer
249
- with open(self.current_file, 'a', encoding='utf-8') as f:
250
- f.write("\n---\n\n")
251
- f.write(f"**Ended:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
252
-
253
- logger.info(f"Ended markdown conversation session: {self.current_file.name}")
254
- self.current_file = None
@@ -1,30 +0,0 @@
1
- """Shared path constants for bone-agent.
2
-
3
- Centralizes APP_ROOT, REPO_ROOT, and tool paths (e.g. ripgrep)
4
- so they can be imported from both core and ui modules without
5
- creating circular dependencies.
6
- """
7
-
8
- import os
9
- import sys
10
- from pathlib import Path
11
-
12
-
13
- def _resolve_app_root() -> Path:
14
- """Return the application root directory.
15
-
16
- For frozen builds (PyInstaller), this is the directory containing
17
- the executable. For source installs, it's two levels up from this file
18
- (i.e. the repo root).
19
- """
20
- if getattr(sys, "frozen", False):
21
- return Path(sys.executable).resolve().parent
22
- return Path(__file__).resolve().parents[2]
23
-
24
-
25
- APP_ROOT = _resolve_app_root()
26
- REPO_ROOT = Path.cwd().resolve()
27
-
28
- # Platform-agnostic ripgrep path: 'rg' on Unix/Linux, 'rg.exe' on Windows
29
- _RG_EXE_NAME = "rg.exe" if os.name == "nt" else "rg"
30
- RG_EXE_PATH = (APP_ROOT / "bin" / _RG_EXE_NAME).resolve()
@@ -1,108 +0,0 @@
1
- """Result parsing utilities for tool outputs."""
2
-
3
- from typing import Optional
4
-
5
-
6
- def extract_exit_code(tool_result: str) -> Optional[int]:
7
- """Parse exit_code from tool result string.
8
-
9
- Args:
10
- tool_result: Tool result content string
11
-
12
- Returns:
13
- Exit code as integer, or None if not found
14
- """
15
- if not isinstance(tool_result, str):
16
- return None
17
- first_line = tool_result.splitlines()[0] if tool_result else ""
18
- if first_line.startswith("exit_code="):
19
- try:
20
- value = first_line.split("=", 1)[1].strip()
21
- value = value.split()[0] if value else value
22
- return int(value)
23
- except ValueError:
24
- return None
25
- return None
26
-
27
-
28
- def extract_metadata_from_result(tool_result: str, key: str) -> Optional[int]:
29
- """Parse metadata like matches_found, lines_read, etc. from tool result.
30
-
31
- Args:
32
- tool_result: Tool result content string
33
- key: Metadata key to extract (e.g., "matches_found", "lines_read")
34
-
35
- Returns:
36
- Extracted value as int, or None if not found
37
- """
38
- if not isinstance(tool_result, str):
39
- return None
40
- for line in tool_result.split('\n'):
41
- if line.startswith(f'{key}='):
42
- try:
43
- return int(line.split('=')[1].split()[0])
44
- except (ValueError, IndexError):
45
- return None
46
- return None
47
-
48
-
49
- def extract_all_metadata(tool_result: str, line_index: int = 0) -> dict:
50
- """Parse entire metadata line into a dictionary.
51
-
52
- Parses space-separated key=value pairs from a specific line.
53
- Follows format defined in src/tools/helpers/formatters.py.
54
-
55
- Args:
56
- tool_result: Tool result string
57
- line_index: Which line to parse (default: 0, use 1 for rg results)
58
-
59
- Returns:
60
- dict with all parsed metadata (e.g., {'exit_code': 0, 'lines_read': 123, ...})
61
- Returns empty dict if tool_result is invalid or line_index out of range
62
- """
63
- if not isinstance(tool_result, str) or not tool_result:
64
- return {}
65
-
66
- lines = tool_result.split('\n')
67
- if line_index >= len(lines):
68
- return {}
69
-
70
- line = lines[line_index].strip()
71
- if not line:
72
- return {}
73
-
74
- metadata = {}
75
- # Parse: exit_code=0 path=file.py lines_read=123 start_line=1
76
- for pair in line.split():
77
- if '=' in pair:
78
- key, value = pair.split('=', 1)
79
- # Skip empty values
80
- if not value:
81
- continue
82
- # Try to parse as int, keep as string if fails
83
- try:
84
- value = int(value)
85
- except ValueError:
86
- # Keep as string (e.g., paths, error messages)
87
- pass
88
- metadata[key] = value
89
-
90
- return metadata
91
-
92
-
93
- def extract_multiple_metadata(tool_result: str, *keys: str, line_index: int = 0) -> dict:
94
- """Extract specific metadata keys from a tool result line.
95
-
96
- Args:
97
- tool_result: Tool result string
98
- *keys: Metadata keys to extract (e.g., 'lines_read', 'start_line')
99
- line_index: Which line to parse (default: 0)
100
-
101
- Returns:
102
- dict mapping keys to their parsed values
103
- Missing keys are not included in the result
104
- """
105
- all_metadata = extract_all_metadata(tool_result, line_index=line_index)
106
-
107
- # Return only requested keys that exist
108
- return {key: all_metadata[key] for key in keys if key in all_metadata}