bone-agent 1.3.3 → 2.0.0

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 (121) 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 -184
  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 -141
  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 -36
  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/targeted_searching.md +0 -10
  25. package/prompts/main/task_lists_pattern.md +0 -8
  26. package/prompts/main/temp_folder.md +0 -9
  27. package/prompts/main/think_before_acting.md +0 -10
  28. package/prompts/main/tone_and_style.md +0 -4
  29. package/prompts/main/tool_preferences.md +0 -24
  30. package/prompts/main/trust_subagent_context.md +0 -21
  31. package/prompts/main/when_to_use_sub_agent.md +0 -7
  32. package/prompts/micro/ask_questions.md +0 -1
  33. package/prompts/micro/batch_independent_calls.md +0 -1
  34. package/prompts/micro/casual_interactions.md +0 -1
  35. package/prompts/micro/code_references.md +0 -1
  36. package/prompts/micro/communication_style.md +0 -1
  37. package/prompts/micro/context_reliability.md +0 -1
  38. package/prompts/micro/conversational_tool_calling.md +0 -1
  39. package/prompts/micro/editing_pattern.md +0 -1
  40. package/prompts/micro/error_handling.md +0 -1
  41. package/prompts/micro/exploration_pattern.md +0 -1
  42. package/prompts/micro/intro.md +0 -1
  43. package/prompts/micro/obsidian.md +0 -4
  44. package/prompts/micro/obsidian_project.md +0 -5
  45. package/prompts/micro/professional_objectivity.md +0 -1
  46. package/prompts/micro/targeted_searching.md +0 -1
  47. package/prompts/micro/task_lists_pattern.md +0 -1
  48. package/prompts/micro/temp_folder.md +0 -1
  49. package/prompts/micro/think_before_acting.md +0 -5
  50. package/prompts/micro/tone_and_style.md +0 -1
  51. package/prompts/micro/tool_preferences.md +0 -1
  52. package/prompts/micro/trust_subagent_context.md +0 -1
  53. package/prompts/micro/when_to_use_sub_agent.md +0 -1
  54. package/requirements.txt +0 -9
  55. package/src/__init__.py +0 -11
  56. package/src/core/__init__.py +0 -1
  57. package/src/core/agentic.py +0 -985
  58. package/src/core/chat_manager.py +0 -1564
  59. package/src/core/config_manager.py +0 -253
  60. package/src/core/cron.py +0 -582
  61. package/src/core/cron_allowlist.py +0 -118
  62. package/src/core/memory.py +0 -145
  63. package/src/core/retry.py +0 -71
  64. package/src/core/sub_agent.py +0 -326
  65. package/src/core/tool_approval.py +0 -220
  66. package/src/core/tool_feedback.py +0 -778
  67. package/src/exceptions.py +0 -79
  68. package/src/llm/__init__.py +0 -1
  69. package/src/llm/client.py +0 -171
  70. package/src/llm/config.py +0 -492
  71. package/src/llm/prompts.py +0 -489
  72. package/src/llm/providers.py +0 -436
  73. package/src/llm/streaming.py +0 -163
  74. package/src/llm/token_tracker.py +0 -384
  75. package/src/tools/__init__.py +0 -212
  76. package/src/tools/constants.py +0 -59
  77. package/src/tools/create_file.py +0 -136
  78. package/src/tools/directory.py +0 -389
  79. package/src/tools/edit.py +0 -545
  80. package/src/tools/file_reader.py +0 -322
  81. package/src/tools/helpers/__init__.py +0 -105
  82. package/src/tools/helpers/base.py +0 -550
  83. package/src/tools/helpers/converters.py +0 -44
  84. package/src/tools/helpers/file_helpers.py +0 -189
  85. package/src/tools/helpers/formatters.py +0 -411
  86. package/src/tools/helpers/loader.py +0 -231
  87. package/src/tools/helpers/parallel_executor.py +0 -231
  88. package/src/tools/helpers/path_resolver.py +0 -232
  89. package/src/tools/helpers/plugin_manifest.py +0 -156
  90. package/src/tools/obsidian.py +0 -96
  91. package/src/tools/review_sub_agent.py +0 -189
  92. package/src/tools/rg_search.py +0 -460
  93. package/src/tools/search_plugins.py +0 -109
  94. package/src/tools/select_option.py +0 -600
  95. package/src/tools/shell.py +0 -302
  96. package/src/tools/sub_agent.py +0 -139
  97. package/src/tools/task_list.py +0 -269
  98. package/src/tools/web_search.py +0 -61
  99. package/src/ui/__init__.py +0 -1
  100. package/src/ui/banner.py +0 -87
  101. package/src/ui/commands.py +0 -2809
  102. package/src/ui/displays.py +0 -214
  103. package/src/ui/loader.py +0 -284
  104. package/src/ui/main.py +0 -647
  105. package/src/ui/prompt_utils.py +0 -113
  106. package/src/ui/setting_selector.py +0 -590
  107. package/src/ui/setup_wizard.py +0 -294
  108. package/src/ui/sub_agent_panel.py +0 -234
  109. package/src/ui/tool_confirmation.py +0 -215
  110. package/src/utils/__init__.py +0 -1
  111. package/src/utils/citation_parser.py +0 -199
  112. package/src/utils/editor.py +0 -158
  113. package/src/utils/gitignore_filter.py +0 -149
  114. package/src/utils/logger.py +0 -254
  115. package/src/utils/paths.py +0 -30
  116. package/src/utils/result_parsers.py +0 -108
  117. package/src/utils/safe_commands.py +0 -243
  118. package/src/utils/settings.py +0 -191
  119. package/src/utils/user_message_logger.py +0 -120
  120. package/src/utils/validation.py +0 -191
  121. 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}