kader 0.1.5__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.
- cli/README.md +169 -0
- cli/__init__.py +5 -0
- cli/__main__.py +6 -0
- cli/app.py +707 -0
- cli/app.tcss +664 -0
- cli/utils.py +68 -0
- cli/widgets/__init__.py +13 -0
- cli/widgets/confirmation.py +309 -0
- cli/widgets/conversation.py +55 -0
- cli/widgets/loading.py +59 -0
- kader/__init__.py +22 -0
- kader/agent/__init__.py +8 -0
- kader/agent/agents.py +126 -0
- kader/agent/base.py +927 -0
- kader/agent/logger.py +170 -0
- kader/config.py +139 -0
- kader/memory/__init__.py +66 -0
- kader/memory/conversation.py +409 -0
- kader/memory/session.py +385 -0
- kader/memory/state.py +211 -0
- kader/memory/types.py +116 -0
- kader/prompts/__init__.py +9 -0
- kader/prompts/agent_prompts.py +27 -0
- kader/prompts/base.py +81 -0
- kader/prompts/templates/planning_agent.j2 +26 -0
- kader/prompts/templates/react_agent.j2 +18 -0
- kader/providers/__init__.py +9 -0
- kader/providers/base.py +581 -0
- kader/providers/mock.py +96 -0
- kader/providers/ollama.py +447 -0
- kader/tools/README.md +483 -0
- kader/tools/__init__.py +130 -0
- kader/tools/base.py +955 -0
- kader/tools/exec_commands.py +249 -0
- kader/tools/filesys.py +650 -0
- kader/tools/filesystem.py +607 -0
- kader/tools/protocol.py +456 -0
- kader/tools/rag.py +555 -0
- kader/tools/todo.py +210 -0
- kader/tools/utils.py +456 -0
- kader/tools/web.py +246 -0
- kader-0.1.5.dist-info/METADATA +321 -0
- kader-0.1.5.dist-info/RECORD +45 -0
- kader-0.1.5.dist-info/WHEEL +4 -0
- kader-0.1.5.dist-info/entry_points.txt +2 -0
kader/tools/todo.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Literal, Optional
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field, ValidationError
|
|
7
|
+
|
|
8
|
+
from kader.tools.base import BaseTool, ParameterSchema, ToolCategory
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TodoStatus(str, Enum):
|
|
12
|
+
"""Status of a todo item."""
|
|
13
|
+
|
|
14
|
+
NOT_STARTED = "not-started"
|
|
15
|
+
IN_PROGRESS = "in-progress"
|
|
16
|
+
COMPLETED = "completed"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TodoItem(BaseModel):
|
|
20
|
+
"""A single item in a todo list."""
|
|
21
|
+
|
|
22
|
+
task: str
|
|
23
|
+
status: TodoStatus = TodoStatus.NOT_STARTED
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TodoToolInput(BaseModel):
|
|
27
|
+
"""Input model for TodoTool."""
|
|
28
|
+
|
|
29
|
+
action: Literal["create", "read", "update", "delete"]
|
|
30
|
+
todo_id: str = Field(
|
|
31
|
+
..., min_length=1, description="Unique identifier for the todo list"
|
|
32
|
+
)
|
|
33
|
+
items: Optional[list[TodoItem]] = Field(
|
|
34
|
+
None, description="List of items for create/update"
|
|
35
|
+
)
|
|
36
|
+
session_id: Optional[str] = Field(None, description="Session ID override")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TodoTool(BaseTool[str]):
|
|
40
|
+
"""
|
|
41
|
+
Tool for managing todo lists associated with an agent's memory session.
|
|
42
|
+
|
|
43
|
+
Allows creating, reading, updating, and deleting todo lists.
|
|
44
|
+
Data is stored as JSON files in ~/.kader/memory/sessions/<session_id>/todos/.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self) -> None:
|
|
48
|
+
"""Initialize the TodoTool."""
|
|
49
|
+
super().__init__(
|
|
50
|
+
name="todo_tool",
|
|
51
|
+
description=(
|
|
52
|
+
"Manage todo lists for planning. "
|
|
53
|
+
"Supports creating, reading, updating, and deleting todo lists. "
|
|
54
|
+
"Each list is identified by a todo_id and contains items with status "
|
|
55
|
+
"(not-started, in-progress, completed)."
|
|
56
|
+
),
|
|
57
|
+
category=ToolCategory.UTILITY,
|
|
58
|
+
parameters=[
|
|
59
|
+
ParameterSchema(
|
|
60
|
+
name="action",
|
|
61
|
+
type="string",
|
|
62
|
+
description="Action to perform: create, read, update, delete",
|
|
63
|
+
enum=["create", "read", "update", "delete"],
|
|
64
|
+
),
|
|
65
|
+
ParameterSchema(
|
|
66
|
+
name="todo_id",
|
|
67
|
+
type="string",
|
|
68
|
+
description="ID of the todo list",
|
|
69
|
+
),
|
|
70
|
+
ParameterSchema(
|
|
71
|
+
name="items",
|
|
72
|
+
type="array",
|
|
73
|
+
description="List of todo items (for create/update). Each item has 'task' and optional 'status'.",
|
|
74
|
+
items_type="object",
|
|
75
|
+
required=False,
|
|
76
|
+
properties=[
|
|
77
|
+
ParameterSchema(
|
|
78
|
+
name="task", type="string", description="Task description"
|
|
79
|
+
),
|
|
80
|
+
ParameterSchema(
|
|
81
|
+
name="status",
|
|
82
|
+
type="string",
|
|
83
|
+
description="Status",
|
|
84
|
+
enum=["not-started", "in-progress", "completed"],
|
|
85
|
+
required=False,
|
|
86
|
+
),
|
|
87
|
+
],
|
|
88
|
+
),
|
|
89
|
+
ParameterSchema(
|
|
90
|
+
name="session_id",
|
|
91
|
+
type="string",
|
|
92
|
+
description="Optional session ID to override the current agent session",
|
|
93
|
+
required=False,
|
|
94
|
+
),
|
|
95
|
+
],
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def execute(self, **kwargs: Any) -> str:
|
|
99
|
+
"""
|
|
100
|
+
Execute the todo tool.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
**kwargs: Arguments matching TodoToolInput
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
JSON string results or success message
|
|
107
|
+
"""
|
|
108
|
+
try:
|
|
109
|
+
# Validate input using Pydantic
|
|
110
|
+
# We handle potential dict items in 'items' list automatically via Pydantic parsing
|
|
111
|
+
input_data = TodoToolInput(**kwargs)
|
|
112
|
+
except ValidationError as e:
|
|
113
|
+
return f"Input Validation Error: {e}"
|
|
114
|
+
|
|
115
|
+
# Resolve session ID
|
|
116
|
+
session_id = input_data.session_id or self._session_id
|
|
117
|
+
if not session_id:
|
|
118
|
+
return "Error: No session ID available. ensure the agent is running with a session or provide 'session_id'."
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
if input_data.action == "create":
|
|
122
|
+
return self._create_todo(
|
|
123
|
+
session_id, input_data.todo_id, input_data.items
|
|
124
|
+
)
|
|
125
|
+
elif input_data.action == "read":
|
|
126
|
+
return self._read_todo(session_id, input_data.todo_id)
|
|
127
|
+
elif input_data.action == "update":
|
|
128
|
+
return self._update_todo(
|
|
129
|
+
session_id, input_data.todo_id, input_data.items
|
|
130
|
+
)
|
|
131
|
+
elif input_data.action == "delete":
|
|
132
|
+
return self._delete_todo(session_id, input_data.todo_id)
|
|
133
|
+
else:
|
|
134
|
+
return f"Error: Unknown action '{input_data.action}'"
|
|
135
|
+
except Exception as e:
|
|
136
|
+
return f"Error executing todo action '{input_data.action}': {str(e)}"
|
|
137
|
+
|
|
138
|
+
async def aexecute(self, **kwargs: Any) -> str:
|
|
139
|
+
"""Asynchronous execution (delegates to synchronous for now)."""
|
|
140
|
+
return self.execute(**kwargs)
|
|
141
|
+
|
|
142
|
+
def get_interruption_message(self, action: str, **kwargs) -> str:
|
|
143
|
+
"""Get interruption message for user confirmation."""
|
|
144
|
+
return f"execute todo_{action}"
|
|
145
|
+
|
|
146
|
+
def _get_todo_path(self, session_id: str, todo_id: str) -> Path:
|
|
147
|
+
"""Get the file path for a todo list."""
|
|
148
|
+
# Hardcoded base path as per requirements: ~/.kader/memory/sessions/
|
|
149
|
+
base_dir = Path.home() / ".kader" / "memory" / "sessions"
|
|
150
|
+
todo_dir = base_dir / session_id / "todos"
|
|
151
|
+
todo_dir.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
return todo_dir / f"{todo_id}.json"
|
|
153
|
+
|
|
154
|
+
def _create_todo(
|
|
155
|
+
self, session_id: str, todo_id: str, items: list[TodoItem] | None
|
|
156
|
+
) -> str:
|
|
157
|
+
"""Create a new todo list."""
|
|
158
|
+
path = self._get_todo_path(session_id, todo_id)
|
|
159
|
+
if path.exists():
|
|
160
|
+
return f"Error: Todo list '{todo_id}' already exists."
|
|
161
|
+
|
|
162
|
+
items_list = items or []
|
|
163
|
+
data = [item.model_dump() for item in items_list]
|
|
164
|
+
|
|
165
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
166
|
+
json.dump(data, f, indent=2)
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
f"Successfully created todo list '{todo_id}' with {len(items_list)} items."
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def _read_todo(self, session_id: str, todo_id: str) -> str:
|
|
173
|
+
"""Read a todo list."""
|
|
174
|
+
path = self._get_todo_path(session_id, todo_id)
|
|
175
|
+
if not path.exists():
|
|
176
|
+
return f"Error: Todo list '{todo_id}' not found."
|
|
177
|
+
|
|
178
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
179
|
+
try:
|
|
180
|
+
data = json.load(f)
|
|
181
|
+
return json.dumps(data, indent=2)
|
|
182
|
+
except json.JSONDecodeError:
|
|
183
|
+
return "Error: Failed to decode todo list JSON."
|
|
184
|
+
|
|
185
|
+
def _update_todo(
|
|
186
|
+
self, session_id: str, todo_id: str, items: list[TodoItem] | None
|
|
187
|
+
) -> str:
|
|
188
|
+
"""Update an existing todo list (overwrite)."""
|
|
189
|
+
path = self._get_todo_path(session_id, todo_id)
|
|
190
|
+
if not path.exists():
|
|
191
|
+
return f"Error: Todo list '{todo_id}' not found. Use 'create' to make a new list."
|
|
192
|
+
|
|
193
|
+
if items is None:
|
|
194
|
+
return "Error: 'items' must be provided for update action."
|
|
195
|
+
|
|
196
|
+
data = [item.model_dump() for item in items]
|
|
197
|
+
|
|
198
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
199
|
+
json.dump(data, f, indent=2)
|
|
200
|
+
|
|
201
|
+
return f"Successfully updated todo list '{todo_id}'."
|
|
202
|
+
|
|
203
|
+
def _delete_todo(self, session_id: str, todo_id: str) -> str:
|
|
204
|
+
"""Delete a todo list."""
|
|
205
|
+
path = self._get_todo_path(session_id, todo_id)
|
|
206
|
+
if not path.exists():
|
|
207
|
+
return f"Error: Todo list '{todo_id}' not found."
|
|
208
|
+
|
|
209
|
+
path.unlink()
|
|
210
|
+
return f"Successfully deleted todo list '{todo_id}'."
|
kader/tools/utils.py
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
"""Shared utility functions for memory backend implementations.
|
|
2
|
+
|
|
3
|
+
This module contains both user-facing string formatters and structured
|
|
4
|
+
helpers used by backends and the composite router. Structured helpers
|
|
5
|
+
enable composition without fragile string parsing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Literal
|
|
12
|
+
|
|
13
|
+
import wcmatch.glob as wcglob
|
|
14
|
+
|
|
15
|
+
from kader.tools.protocol import FileInfo as _FileInfo
|
|
16
|
+
from kader.tools.protocol import GrepMatch as _GrepMatch
|
|
17
|
+
|
|
18
|
+
EMPTY_CONTENT_WARNING = "System reminder: File exists but has empty contents"
|
|
19
|
+
MAX_LINE_LENGTH = 10000
|
|
20
|
+
LINE_NUMBER_WIDTH = 6
|
|
21
|
+
TOOL_RESULT_TOKEN_LIMIT = 20000 # Same threshold as eviction
|
|
22
|
+
TRUNCATION_GUIDANCE = (
|
|
23
|
+
"... [results truncated, try being more specific with your parameters]"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Re-export protocol types for backwards compatibility
|
|
27
|
+
FileInfo = _FileInfo
|
|
28
|
+
GrepMatch = _GrepMatch
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def sanitize_tool_call_id(tool_call_id: str) -> str:
|
|
32
|
+
r"""Sanitize tool_call_id to prevent path traversal and separator issues.
|
|
33
|
+
|
|
34
|
+
Replaces dangerous characters (., /, \) with underscores.
|
|
35
|
+
"""
|
|
36
|
+
sanitized = tool_call_id.replace(".", "_").replace("/", "_").replace("\\", "_")
|
|
37
|
+
return sanitized
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def format_content_with_line_numbers(
|
|
41
|
+
content: str | list[str],
|
|
42
|
+
start_line: int = 1,
|
|
43
|
+
) -> str:
|
|
44
|
+
"""Format file content with line numbers (cat -n style).
|
|
45
|
+
|
|
46
|
+
Chunks lines longer than MAX_LINE_LENGTH with continuation markers (e.g., 5.1, 5.2).
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
content: File content as string or list of lines
|
|
50
|
+
start_line: Starting line number (default: 1)
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Formatted content with line numbers and continuation markers
|
|
54
|
+
"""
|
|
55
|
+
if isinstance(content, str):
|
|
56
|
+
lines = content.split("\n")
|
|
57
|
+
if lines and lines[-1] == "":
|
|
58
|
+
lines = lines[:-1]
|
|
59
|
+
else:
|
|
60
|
+
lines = content
|
|
61
|
+
|
|
62
|
+
result_lines = []
|
|
63
|
+
for i, line in enumerate(lines):
|
|
64
|
+
line_num = i + start_line
|
|
65
|
+
|
|
66
|
+
if len(line) <= MAX_LINE_LENGTH:
|
|
67
|
+
result_lines.append(f"{line_num:{LINE_NUMBER_WIDTH}d}\t{line}")
|
|
68
|
+
else:
|
|
69
|
+
# Split long line into chunks with continuation markers
|
|
70
|
+
num_chunks = (len(line) + MAX_LINE_LENGTH - 1) // MAX_LINE_LENGTH
|
|
71
|
+
for chunk_idx in range(num_chunks):
|
|
72
|
+
start = chunk_idx * MAX_LINE_LENGTH
|
|
73
|
+
end = min(start + MAX_LINE_LENGTH, len(line))
|
|
74
|
+
chunk = line[start:end]
|
|
75
|
+
if chunk_idx == 0:
|
|
76
|
+
# First chunk: use normal line number
|
|
77
|
+
result_lines.append(f"{line_num:{LINE_NUMBER_WIDTH}d}\t{chunk}")
|
|
78
|
+
else:
|
|
79
|
+
# Continuation chunks: use decimal notation (e.g., 5.1, 5.2)
|
|
80
|
+
continuation_marker = f"{line_num}.{chunk_idx}"
|
|
81
|
+
result_lines.append(
|
|
82
|
+
f"{continuation_marker:>{LINE_NUMBER_WIDTH}}\t{chunk}"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return "\n".join(result_lines)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def check_empty_content(content: str) -> str | None:
|
|
89
|
+
"""Check if content is empty and return warning message.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
content: Content to check
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Warning message if empty, None otherwise
|
|
96
|
+
"""
|
|
97
|
+
if not content or content.strip() == "":
|
|
98
|
+
return EMPTY_CONTENT_WARNING
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def file_data_to_string(file_data: dict[str, Any]) -> str:
|
|
103
|
+
"""Convert FileData to plain string content.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
file_data: FileData dict with 'content' key
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Content as string with lines joined by newlines
|
|
110
|
+
"""
|
|
111
|
+
return "\n".join(file_data["content"])
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def create_file_data(content: str, created_at: str | None = None) -> dict[str, Any]:
|
|
115
|
+
"""Create a FileData object with timestamps.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
content: File content as string
|
|
119
|
+
created_at: Optional creation timestamp (ISO format)
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
FileData dict with content and timestamps
|
|
123
|
+
"""
|
|
124
|
+
lines = content.split("\n") if isinstance(content, str) else content
|
|
125
|
+
now = datetime.now(UTC).isoformat()
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
"content": lines,
|
|
129
|
+
"created_at": created_at or now,
|
|
130
|
+
"modified_at": now,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def update_file_data(file_data: dict[str, Any], content: str) -> dict[str, Any]:
|
|
135
|
+
"""Update FileData with new content, preserving creation timestamp.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
file_data: Existing FileData dict
|
|
139
|
+
content: New content as string
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Updated FileData dict
|
|
143
|
+
"""
|
|
144
|
+
lines = content.split("\n") if isinstance(content, str) else content
|
|
145
|
+
now = datetime.now(UTC).isoformat()
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
"content": lines,
|
|
149
|
+
"created_at": file_data["created_at"],
|
|
150
|
+
"modified_at": now,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def format_read_response(
|
|
155
|
+
file_data: dict[str, Any],
|
|
156
|
+
offset: int,
|
|
157
|
+
limit: int,
|
|
158
|
+
) -> str:
|
|
159
|
+
"""Format file data for read response with line numbers.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
file_data: FileData dict
|
|
163
|
+
offset: Line offset (0-indexed)
|
|
164
|
+
limit: Maximum number of lines
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
Formatted content or error message
|
|
168
|
+
"""
|
|
169
|
+
content = file_data_to_string(file_data)
|
|
170
|
+
empty_msg = check_empty_content(content)
|
|
171
|
+
if empty_msg:
|
|
172
|
+
return empty_msg
|
|
173
|
+
|
|
174
|
+
lines = content.splitlines()
|
|
175
|
+
start_idx = offset
|
|
176
|
+
end_idx = min(start_idx + limit, len(lines))
|
|
177
|
+
|
|
178
|
+
if start_idx >= len(lines):
|
|
179
|
+
return f"Error: Line offset {offset} exceeds file length ({len(lines)} lines)"
|
|
180
|
+
|
|
181
|
+
selected_lines = lines[start_idx:end_idx]
|
|
182
|
+
return format_content_with_line_numbers(selected_lines, start_line=start_idx + 1)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def perform_string_replacement(
|
|
186
|
+
content: str,
|
|
187
|
+
old_string: str,
|
|
188
|
+
new_string: str,
|
|
189
|
+
replace_all: bool,
|
|
190
|
+
) -> tuple[str, int] | str:
|
|
191
|
+
"""Perform string replacement with occurrence validation.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
content: Original content
|
|
195
|
+
old_string: String to replace
|
|
196
|
+
new_string: Replacement string
|
|
197
|
+
replace_all: Whether to replace all occurrences
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Tuple of (new_content, occurrences) on success, or error message string
|
|
201
|
+
"""
|
|
202
|
+
occurrences = content.count(old_string)
|
|
203
|
+
|
|
204
|
+
if occurrences == 0:
|
|
205
|
+
return f"Error: String not found in file: '{old_string}'"
|
|
206
|
+
|
|
207
|
+
if occurrences > 1 and not replace_all:
|
|
208
|
+
return f"Error: String '{old_string}' appears {occurrences} times in file. Use replace_all=True to replace all instances, or provide a more specific string with surrounding context."
|
|
209
|
+
|
|
210
|
+
new_content = content.replace(old_string, new_string)
|
|
211
|
+
return new_content, occurrences
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def truncate_if_too_long(result: list[str] | str) -> list[str] | str:
|
|
215
|
+
"""Truncate list or string result if it exceeds token limit (rough estimate: 4 chars/token)."""
|
|
216
|
+
if isinstance(result, list):
|
|
217
|
+
total_chars = sum(len(item) for item in result)
|
|
218
|
+
if total_chars > TOOL_RESULT_TOKEN_LIMIT * 4:
|
|
219
|
+
return result[
|
|
220
|
+
: len(result) * TOOL_RESULT_TOKEN_LIMIT * 4 // total_chars
|
|
221
|
+
] + [TRUNCATION_GUIDANCE]
|
|
222
|
+
return result
|
|
223
|
+
# string
|
|
224
|
+
if len(result) > TOOL_RESULT_TOKEN_LIMIT * 4:
|
|
225
|
+
return result[: TOOL_RESULT_TOKEN_LIMIT * 4] + "\n" + TRUNCATION_GUIDANCE
|
|
226
|
+
return result
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _validate_path(path: str | None) -> str:
|
|
230
|
+
"""Validate and normalize a path.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
path: Path to validate
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
Normalized path starting with /
|
|
237
|
+
|
|
238
|
+
Raises:
|
|
239
|
+
ValueError: If path is invalid
|
|
240
|
+
"""
|
|
241
|
+
path = path or "/"
|
|
242
|
+
if not path or path.strip() == "":
|
|
243
|
+
raise ValueError("Path cannot be empty")
|
|
244
|
+
|
|
245
|
+
normalized = path if path.startswith("/") else "/" + path
|
|
246
|
+
|
|
247
|
+
if not normalized.endswith("/"):
|
|
248
|
+
normalized += "/"
|
|
249
|
+
|
|
250
|
+
return normalized
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _glob_search_files(
|
|
254
|
+
files: dict[str, Any],
|
|
255
|
+
pattern: str,
|
|
256
|
+
path: str = "/",
|
|
257
|
+
) -> str:
|
|
258
|
+
"""Search files dict for paths matching glob pattern.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
files: Dictionary of file paths to FileData.
|
|
262
|
+
pattern: Glob pattern (e.g., "*.py", "**/*.ts").
|
|
263
|
+
path: Base path to search from.
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
Newline-separated file paths, sorted by modification time (most recent first).
|
|
267
|
+
Returns "No files found" if no matches.
|
|
268
|
+
|
|
269
|
+
Example:
|
|
270
|
+
```python
|
|
271
|
+
files = {"/src/main.py": FileData(...), "/test.py": FileData(...)}
|
|
272
|
+
_glob_search_files(files, "*.py", "/")
|
|
273
|
+
# Returns: "/test.py\n/src/main.py" (sorted by modified_at)
|
|
274
|
+
```
|
|
275
|
+
"""
|
|
276
|
+
try:
|
|
277
|
+
normalized_path = _validate_path(path)
|
|
278
|
+
except ValueError:
|
|
279
|
+
return "No files found"
|
|
280
|
+
|
|
281
|
+
filtered = {fp: fd for fp, fd in files.items() if fp.startswith(normalized_path)}
|
|
282
|
+
|
|
283
|
+
# Respect standard glob semantics:
|
|
284
|
+
# - Patterns without path separators (e.g., "*.py") match only in the current
|
|
285
|
+
# directory (non-recursive) relative to `path`.
|
|
286
|
+
# - Use "**" explicitly for recursive matching.
|
|
287
|
+
effective_pattern = pattern
|
|
288
|
+
|
|
289
|
+
matches = []
|
|
290
|
+
for file_path, file_data in filtered.items():
|
|
291
|
+
relative = file_path[len(normalized_path) :].lstrip("/")
|
|
292
|
+
if not relative:
|
|
293
|
+
relative = file_path.split("/")[-1]
|
|
294
|
+
|
|
295
|
+
if wcglob.globmatch(
|
|
296
|
+
relative, effective_pattern, flags=wcglob.BRACE | wcglob.GLOBSTAR
|
|
297
|
+
):
|
|
298
|
+
matches.append((file_path, file_data["modified_at"]))
|
|
299
|
+
|
|
300
|
+
matches.sort(key=lambda x: x[1], reverse=True)
|
|
301
|
+
|
|
302
|
+
if not matches:
|
|
303
|
+
return "No files found"
|
|
304
|
+
|
|
305
|
+
return "\n".join(fp for fp, _ in matches)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _format_grep_results(
|
|
309
|
+
results: dict[str, list[tuple[int, str]]],
|
|
310
|
+
output_mode: Literal["files_with_matches", "content", "count"],
|
|
311
|
+
) -> str:
|
|
312
|
+
"""Format grep search results based on output mode.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
results: Dictionary mapping file paths to list of (line_num, line_content) tuples
|
|
316
|
+
output_mode: Output format - "files_with_matches", "content", or "count"
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
Formatted string output
|
|
320
|
+
"""
|
|
321
|
+
if output_mode == "files_with_matches":
|
|
322
|
+
return "\n".join(sorted(results.keys()))
|
|
323
|
+
if output_mode == "count":
|
|
324
|
+
lines = []
|
|
325
|
+
for file_path in sorted(results.keys()):
|
|
326
|
+
count = len(results[file_path])
|
|
327
|
+
lines.append(f"{file_path}: {count}")
|
|
328
|
+
return "\n".join(lines)
|
|
329
|
+
lines = []
|
|
330
|
+
for file_path in sorted(results.keys()):
|
|
331
|
+
lines.append(f"{file_path}:")
|
|
332
|
+
for line_num, line in results[file_path]:
|
|
333
|
+
lines.append(f" {line_num}: {line}")
|
|
334
|
+
return "\n".join(lines)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _grep_search_files(
|
|
338
|
+
files: dict[str, Any],
|
|
339
|
+
pattern: str,
|
|
340
|
+
path: str | None = None,
|
|
341
|
+
glob: str | None = None,
|
|
342
|
+
output_mode: Literal[
|
|
343
|
+
"files_with_matches", "content", "count"
|
|
344
|
+
] = "files_with_matches",
|
|
345
|
+
) -> str:
|
|
346
|
+
"""Search file contents for regex pattern.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
files: Dictionary of file paths to FileData.
|
|
350
|
+
pattern: Regex pattern to search for.
|
|
351
|
+
path: Base path to search from.
|
|
352
|
+
glob: Optional glob pattern to filter files (e.g., "*.py").
|
|
353
|
+
output_mode: Output format - "files_with_matches", "content", or "count".
|
|
354
|
+
|
|
355
|
+
Returns:
|
|
356
|
+
Formatted search results. Returns "No matches found" if no results.
|
|
357
|
+
|
|
358
|
+
Example:
|
|
359
|
+
```python
|
|
360
|
+
files = {"/file.py": FileData(content=["import os", "print('hi')"], ...)}
|
|
361
|
+
_grep_search_files(files, "import", "/")
|
|
362
|
+
# Returns: "/file.py" (with output_mode="files_with_matches")
|
|
363
|
+
```
|
|
364
|
+
"""
|
|
365
|
+
try:
|
|
366
|
+
regex = re.compile(pattern)
|
|
367
|
+
except re.error as e:
|
|
368
|
+
return f"Invalid regex pattern: {e}"
|
|
369
|
+
|
|
370
|
+
try:
|
|
371
|
+
normalized_path = _validate_path(path)
|
|
372
|
+
except ValueError:
|
|
373
|
+
return "No matches found"
|
|
374
|
+
|
|
375
|
+
filtered = {fp: fd for fp, fd in files.items() if fp.startswith(normalized_path)}
|
|
376
|
+
|
|
377
|
+
if glob:
|
|
378
|
+
filtered = {
|
|
379
|
+
fp: fd
|
|
380
|
+
for fp, fd in filtered.items()
|
|
381
|
+
if wcglob.globmatch(Path(fp).name, glob, flags=wcglob.BRACE)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
results: dict[str, list[tuple[int, str]]] = {}
|
|
385
|
+
for file_path, file_data in filtered.items():
|
|
386
|
+
for line_num, line in enumerate(file_data["content"], 1):
|
|
387
|
+
if regex.search(line):
|
|
388
|
+
if file_path not in results:
|
|
389
|
+
results[file_path] = []
|
|
390
|
+
results[file_path].append((line_num, line))
|
|
391
|
+
|
|
392
|
+
if not results:
|
|
393
|
+
return "No matches found"
|
|
394
|
+
return _format_grep_results(results, output_mode)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
# -------- Structured helpers for composition --------
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def grep_matches_from_files(
|
|
401
|
+
files: dict[str, Any],
|
|
402
|
+
pattern: str,
|
|
403
|
+
path: str | None = None,
|
|
404
|
+
glob: str | None = None,
|
|
405
|
+
) -> list[GrepMatch] | str:
|
|
406
|
+
"""Return structured grep matches from an in-memory files mapping.
|
|
407
|
+
|
|
408
|
+
Returns a list of GrepMatch on success, or a string for invalid inputs
|
|
409
|
+
(e.g., invalid regex). We deliberately do not raise here to keep backends
|
|
410
|
+
non-throwing in tool contexts and preserve user-facing error messages.
|
|
411
|
+
"""
|
|
412
|
+
try:
|
|
413
|
+
regex = re.compile(pattern)
|
|
414
|
+
except re.error as e:
|
|
415
|
+
return f"Invalid regex pattern: {e}"
|
|
416
|
+
|
|
417
|
+
try:
|
|
418
|
+
normalized_path = _validate_path(path)
|
|
419
|
+
except ValueError:
|
|
420
|
+
return []
|
|
421
|
+
|
|
422
|
+
filtered = {fp: fd for fp, fd in files.items() if fp.startswith(normalized_path)}
|
|
423
|
+
|
|
424
|
+
if glob:
|
|
425
|
+
filtered = {
|
|
426
|
+
fp: fd
|
|
427
|
+
for fp, fd in filtered.items()
|
|
428
|
+
if wcglob.globmatch(Path(fp).name, glob, flags=wcglob.BRACE)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
matches: list[GrepMatch] = []
|
|
432
|
+
for file_path, file_data in filtered.items():
|
|
433
|
+
for line_num, line in enumerate(file_data["content"], 1):
|
|
434
|
+
if regex.search(line):
|
|
435
|
+
matches.append({"path": file_path, "line": int(line_num), "text": line})
|
|
436
|
+
return matches
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def build_grep_results_dict(
|
|
440
|
+
matches: list[GrepMatch],
|
|
441
|
+
) -> dict[str, list[tuple[int, str]]]:
|
|
442
|
+
"""Group structured matches into the legacy dict form used by formatters."""
|
|
443
|
+
grouped: dict[str, list[tuple[int, str]]] = {}
|
|
444
|
+
for m in matches:
|
|
445
|
+
grouped.setdefault(m["path"], []).append((m["line"], m["text"]))
|
|
446
|
+
return grouped
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def format_grep_matches(
|
|
450
|
+
matches: list[GrepMatch],
|
|
451
|
+
output_mode: Literal["files_with_matches", "content", "count"],
|
|
452
|
+
) -> str:
|
|
453
|
+
"""Format structured grep matches using existing formatting logic."""
|
|
454
|
+
if not matches:
|
|
455
|
+
return "No matches found"
|
|
456
|
+
return _format_grep_results(build_grep_results_dict(matches), output_mode)
|