janito 2.3.0__py3-none-any.whl → 2.3.1__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.
Files changed (93) hide show
  1. janito/__init__.py +6 -6
  2. janito/cli/chat_mode/shell/autocomplete.py +21 -21
  3. janito/cli/chat_mode/shell/commands/clear.py +12 -12
  4. janito/cli/chat_mode/shell/commands/multi.py +51 -51
  5. janito/cli/chat_mode/shell/input_history.py +62 -62
  6. janito/cli/cli_commands/list_models.py +35 -35
  7. janito/cli/cli_commands/list_providers.py +9 -9
  8. janito/cli/cli_commands/list_tools.py +53 -53
  9. janito/cli/cli_commands/model_selection.py +50 -50
  10. janito/cli/cli_commands/model_utils.py +95 -95
  11. janito/cli/cli_commands/set_api_key.py +19 -19
  12. janito/cli/cli_commands/show_config.py +51 -51
  13. janito/cli/cli_commands/show_system_prompt.py +62 -62
  14. janito/cli/core/__init__.py +4 -4
  15. janito/cli/core/event_logger.py +59 -59
  16. janito/cli/core/getters.py +33 -33
  17. janito/cli/core/unsetters.py +54 -54
  18. janito/cli/single_shot_mode/__init__.py +6 -6
  19. janito/config.py +5 -5
  20. janito/config_manager.py +112 -112
  21. janito/drivers/anthropic/driver.py +113 -113
  22. janito/formatting_token.py +54 -54
  23. janito/i18n/__init__.py +35 -35
  24. janito/i18n/messages.py +23 -23
  25. janito/i18n/pt.py +47 -47
  26. janito/llm/__init__.py +5 -5
  27. janito/llm/agent.py +443 -443
  28. janito/llm/auth.py +63 -63
  29. janito/llm/driver_config_builder.py +34 -34
  30. janito/llm/driver_input.py +12 -12
  31. janito/llm/message_parts.py +60 -60
  32. janito/llm/model.py +38 -38
  33. janito/llm/provider.py +196 -196
  34. janito/provider_registry.py +176 -176
  35. janito/providers/anthropic/model_info.py +22 -22
  36. janito/providers/anthropic/provider.py +2 -0
  37. janito/providers/azure_openai/model_info.py +16 -16
  38. janito/providers/azure_openai/provider.py +3 -0
  39. janito/providers/deepseek/__init__.py +1 -1
  40. janito/providers/deepseek/model_info.py +16 -16
  41. janito/providers/deepseek/provider.py +94 -91
  42. janito/providers/google/provider.py +3 -0
  43. janito/providers/mistralai/provider.py +3 -0
  44. janito/providers/openai/provider.py +4 -0
  45. janito/tools/adapters/__init__.py +1 -1
  46. janito/tools/adapters/local/ask_user.py +102 -102
  47. janito/tools/adapters/local/copy_file.py +84 -84
  48. janito/tools/adapters/local/create_directory.py +69 -69
  49. janito/tools/adapters/local/create_file.py +82 -82
  50. janito/tools/adapters/local/fetch_url.py +97 -97
  51. janito/tools/adapters/local/find_files.py +138 -138
  52. janito/tools/adapters/local/get_file_outline/__init__.py +1 -1
  53. janito/tools/adapters/local/get_file_outline/core.py +117 -117
  54. janito/tools/adapters/local/get_file_outline/java_outline.py +40 -40
  55. janito/tools/adapters/local/get_file_outline/markdown_outline.py +14 -14
  56. janito/tools/adapters/local/get_file_outline/python_outline.py +303 -303
  57. janito/tools/adapters/local/get_file_outline/python_outline_v2.py +156 -156
  58. janito/tools/adapters/local/get_file_outline/search_outline.py +33 -33
  59. janito/tools/adapters/local/python_code_run.py +166 -166
  60. janito/tools/adapters/local/python_command_run.py +164 -164
  61. janito/tools/adapters/local/python_file_run.py +163 -163
  62. janito/tools/adapters/local/run_bash_command.py +176 -176
  63. janito/tools/adapters/local/run_powershell_command.py +219 -219
  64. janito/tools/adapters/local/search_text/__init__.py +1 -1
  65. janito/tools/adapters/local/search_text/core.py +201 -201
  66. janito/tools/adapters/local/search_text/pattern_utils.py +73 -73
  67. janito/tools/adapters/local/search_text/traverse_directory.py +145 -145
  68. janito/tools/adapters/local/validate_file_syntax/__init__.py +1 -1
  69. janito/tools/adapters/local/validate_file_syntax/core.py +106 -106
  70. janito/tools/adapters/local/validate_file_syntax/css_validator.py +35 -35
  71. janito/tools/adapters/local/validate_file_syntax/html_validator.py +93 -93
  72. janito/tools/adapters/local/validate_file_syntax/js_validator.py +27 -27
  73. janito/tools/adapters/local/validate_file_syntax/json_validator.py +6 -6
  74. janito/tools/adapters/local/validate_file_syntax/markdown_validator.py +109 -109
  75. janito/tools/adapters/local/validate_file_syntax/ps1_validator.py +32 -32
  76. janito/tools/adapters/local/validate_file_syntax/python_validator.py +5 -5
  77. janito/tools/adapters/local/validate_file_syntax/xml_validator.py +11 -11
  78. janito/tools/adapters/local/validate_file_syntax/yaml_validator.py +6 -6
  79. janito/tools/adapters/local/view_file.py +167 -167
  80. janito/tools/inspect_registry.py +17 -17
  81. janito/tools/tool_base.py +105 -105
  82. janito/tools/tool_events.py +58 -58
  83. janito/tools/tool_run_exception.py +12 -12
  84. janito/tools/tool_use_tracker.py +81 -81
  85. janito/tools/tool_utils.py +45 -45
  86. janito/tools/tools_schema.py +104 -104
  87. janito/version.py +4 -4
  88. {janito-2.3.0.dist-info → janito-2.3.1.dist-info}/METADATA +390 -388
  89. {janito-2.3.0.dist-info → janito-2.3.1.dist-info}/RECORD +93 -93
  90. {janito-2.3.0.dist-info → janito-2.3.1.dist-info}/WHEEL +0 -0
  91. {janito-2.3.0.dist-info → janito-2.3.1.dist-info}/entry_points.txt +0 -0
  92. {janito-2.3.0.dist-info → janito-2.3.1.dist-info}/licenses/LICENSE +0 -0
  93. {janito-2.3.0.dist-info → janito-2.3.1.dist-info}/top_level.txt +0 -0
@@ -1,201 +1,201 @@
1
- from janito.tools.tool_base import ToolBase
2
- from janito.report_events import ReportAction
3
- from janito.tools.adapters.local.adapter import register_local_tool
4
- from janito.tools.tool_utils import pluralize, display_path
5
- from janito.i18n import tr
6
- import os
7
- from .pattern_utils import prepare_pattern, format_result, summarize_total
8
- from .match_lines import read_file_lines
9
- from .traverse_directory import traverse_directory
10
-
11
-
12
- from janito.tools.adapters.local.adapter import register_local_tool as register_tool
13
-
14
-
15
- @register_tool
16
- class SearchTextTool(ToolBase):
17
- """
18
- Search for a text pattern (regex or plain string) in all files within one or more directories or file paths and return matching lines or counts. Respects .gitignore.
19
- Args:
20
- paths (str): String of one or more paths (space-separated) to search in. Each path can be a directory or a file.
21
- pattern (str): Regex pattern or plain text substring to search for in files. Must not be empty. Tries regex first, falls back to substring if regex is invalid.
22
- is_regex (bool): If True, treat pattern as a regular expression. If False, treat as plain text (default).
23
- case_sensitive (bool): If False, perform a case-insensitive search. Default is True (case sensitive).
24
- max_depth (int, optional): Maximum directory depth to search. If 0 (default), search is recursive with no depth limit. If >0, limits recursion to that depth. Setting max_depth=1 disables recursion (only top-level directory). Ignored for file paths.
25
- max_results (int, optional): Maximum number of results to return. Defaults to 100. 0 means no limit.
26
- count_only (bool): If True, return only the count of matches per file and total, not the matching lines. Default is False.
27
- Returns:
28
- str: If count_only is False, matching lines from files as a newline-separated string, each formatted as 'filepath:lineno: line'.
29
- If count_only is True, returns per-file and total match counts.
30
- If max_results is reached, appends a note to the output.
31
- """
32
-
33
- tool_name = "search_text"
34
-
35
- def _handle_file(
36
- self,
37
- search_path,
38
- pattern,
39
- regex,
40
- use_regex,
41
- case_sensitive,
42
- max_results,
43
- total_results,
44
- count_only,
45
- ):
46
- if count_only:
47
- match_count, dir_limit_reached, _ = read_file_lines(
48
- search_path,
49
- pattern,
50
- regex,
51
- use_regex,
52
- case_sensitive,
53
- True,
54
- max_results,
55
- total_results,
56
- )
57
- per_file_counts = [(search_path, match_count)] if match_count > 0 else []
58
- return [], dir_limit_reached, per_file_counts
59
- else:
60
- dir_output, dir_limit_reached, match_count_list = read_file_lines(
61
- search_path,
62
- pattern,
63
- regex,
64
- use_regex,
65
- case_sensitive,
66
- False,
67
- max_results,
68
- total_results,
69
- )
70
- per_file_counts = (
71
- [(search_path, len(match_count_list))]
72
- if match_count_list and len(match_count_list) > 0
73
- else []
74
- )
75
- return dir_output, dir_limit_reached, per_file_counts
76
-
77
- def _handle_path(
78
- self,
79
- search_path,
80
- pattern,
81
- regex,
82
- use_regex,
83
- case_sensitive,
84
- max_depth,
85
- max_results,
86
- total_results,
87
- count_only,
88
- ):
89
- info_str = tr(
90
- "🔍 Search {search_type} '{pattern}' in '{disp_path}'",
91
- search_type=("regex" if use_regex else "text"),
92
- pattern=pattern,
93
- disp_path=display_path(search_path),
94
- )
95
- if max_depth > 0:
96
- info_str += tr(" [max_depth={max_depth}]", max_depth=max_depth)
97
- if count_only:
98
- info_str += " [count]"
99
- self.report_action(info_str, ReportAction.READ)
100
- if os.path.isfile(search_path):
101
- dir_output, dir_limit_reached, per_file_counts = self._handle_file(
102
- search_path,
103
- pattern,
104
- regex,
105
- use_regex,
106
- case_sensitive,
107
- max_results,
108
- total_results,
109
- count_only,
110
- )
111
- else:
112
- if count_only:
113
- per_file_counts, dir_limit_reached, _ = traverse_directory(
114
- search_path,
115
- pattern,
116
- regex,
117
- use_regex,
118
- case_sensitive,
119
- max_depth,
120
- max_results,
121
- total_results,
122
- True,
123
- )
124
- dir_output = []
125
- else:
126
- dir_output, dir_limit_reached, per_file_counts = traverse_directory(
127
- search_path,
128
- pattern,
129
- regex,
130
- use_regex,
131
- case_sensitive,
132
- max_depth,
133
- max_results,
134
- total_results,
135
- False,
136
- )
137
- count = sum(count for _, count in per_file_counts)
138
- file_word = pluralize("match", count)
139
- num_files = len(per_file_counts)
140
- file_label = pluralize("file", num_files)
141
- file_word_max = file_word + (" (max)" if dir_limit_reached else "")
142
- self.report_success(
143
- tr(
144
- " ✅ {count} {file_word} from {num_files} {file_label}",
145
- count=count,
146
- file_word=file_word_max,
147
- num_files=num_files,
148
- file_label=file_label,
149
- ),
150
- ReportAction.READ,
151
- )
152
- return info_str, dir_output, dir_limit_reached, per_file_counts
153
-
154
- def run(
155
- self,
156
- paths: str,
157
- pattern: str,
158
- is_regex: bool = False,
159
- case_sensitive: bool = False,
160
- max_depth: int = 0,
161
- max_results: int = 100,
162
- count_only: bool = False,
163
- ) -> str:
164
- regex, use_regex, error_msg = prepare_pattern(
165
- pattern, is_regex, case_sensitive, self.report_error, self.report_warning
166
- )
167
- if error_msg:
168
- return error_msg
169
- paths_list = paths.split()
170
- results = []
171
- all_per_file_counts = []
172
- for search_path in paths_list:
173
- info_str, dir_output, dir_limit_reached, per_file_counts = (
174
- self._handle_path(
175
- search_path,
176
- pattern,
177
- regex,
178
- use_regex,
179
- case_sensitive,
180
- max_depth,
181
- max_results,
182
- 0,
183
- count_only,
184
- )
185
- )
186
- if count_only:
187
- all_per_file_counts.extend(per_file_counts)
188
- result_str = format_result(
189
- pattern,
190
- use_regex,
191
- dir_output,
192
- dir_limit_reached,
193
- count_only,
194
- per_file_counts,
195
- )
196
- results.append(info_str + "\n" + result_str)
197
- if dir_limit_reached:
198
- break
199
- if count_only:
200
- results.append(summarize_total(all_per_file_counts))
201
- return "\n\n".join(results)
1
+ from janito.tools.tool_base import ToolBase
2
+ from janito.report_events import ReportAction
3
+ from janito.tools.adapters.local.adapter import register_local_tool
4
+ from janito.tools.tool_utils import pluralize, display_path
5
+ from janito.i18n import tr
6
+ import os
7
+ from .pattern_utils import prepare_pattern, format_result, summarize_total
8
+ from .match_lines import read_file_lines
9
+ from .traverse_directory import traverse_directory
10
+
11
+
12
+ from janito.tools.adapters.local.adapter import register_local_tool as register_tool
13
+
14
+
15
+ @register_tool
16
+ class SearchTextTool(ToolBase):
17
+ """
18
+ Search for a text pattern (regex or plain string) in all files within one or more directories or file paths and return matching lines or counts. Respects .gitignore.
19
+ Args:
20
+ paths (str): String of one or more paths (space-separated) to search in. Each path can be a directory or a file.
21
+ pattern (str): Regex pattern or plain text substring to search for in files. Must not be empty. Tries regex first, falls back to substring if regex is invalid.
22
+ is_regex (bool): If True, treat pattern as a regular expression. If False, treat as plain text (default).
23
+ case_sensitive (bool): If False, perform a case-insensitive search. Default is True (case sensitive).
24
+ max_depth (int, optional): Maximum directory depth to search. If 0 (default), search is recursive with no depth limit. If >0, limits recursion to that depth. Setting max_depth=1 disables recursion (only top-level directory). Ignored for file paths.
25
+ max_results (int, optional): Maximum number of results to return. Defaults to 100. 0 means no limit.
26
+ count_only (bool): If True, return only the count of matches per file and total, not the matching lines. Default is False.
27
+ Returns:
28
+ str: If count_only is False, matching lines from files as a newline-separated string, each formatted as 'filepath:lineno: line'.
29
+ If count_only is True, returns per-file and total match counts.
30
+ If max_results is reached, appends a note to the output.
31
+ """
32
+
33
+ tool_name = "search_text"
34
+
35
+ def _handle_file(
36
+ self,
37
+ search_path,
38
+ pattern,
39
+ regex,
40
+ use_regex,
41
+ case_sensitive,
42
+ max_results,
43
+ total_results,
44
+ count_only,
45
+ ):
46
+ if count_only:
47
+ match_count, dir_limit_reached, _ = read_file_lines(
48
+ search_path,
49
+ pattern,
50
+ regex,
51
+ use_regex,
52
+ case_sensitive,
53
+ True,
54
+ max_results,
55
+ total_results,
56
+ )
57
+ per_file_counts = [(search_path, match_count)] if match_count > 0 else []
58
+ return [], dir_limit_reached, per_file_counts
59
+ else:
60
+ dir_output, dir_limit_reached, match_count_list = read_file_lines(
61
+ search_path,
62
+ pattern,
63
+ regex,
64
+ use_regex,
65
+ case_sensitive,
66
+ False,
67
+ max_results,
68
+ total_results,
69
+ )
70
+ per_file_counts = (
71
+ [(search_path, len(match_count_list))]
72
+ if match_count_list and len(match_count_list) > 0
73
+ else []
74
+ )
75
+ return dir_output, dir_limit_reached, per_file_counts
76
+
77
+ def _handle_path(
78
+ self,
79
+ search_path,
80
+ pattern,
81
+ regex,
82
+ use_regex,
83
+ case_sensitive,
84
+ max_depth,
85
+ max_results,
86
+ total_results,
87
+ count_only,
88
+ ):
89
+ info_str = tr(
90
+ "🔍 Search {search_type} '{pattern}' in '{disp_path}'",
91
+ search_type=("regex" if use_regex else "text"),
92
+ pattern=pattern,
93
+ disp_path=display_path(search_path),
94
+ )
95
+ if max_depth > 0:
96
+ info_str += tr(" [max_depth={max_depth}]", max_depth=max_depth)
97
+ if count_only:
98
+ info_str += " [count]"
99
+ self.report_action(info_str, ReportAction.READ)
100
+ if os.path.isfile(search_path):
101
+ dir_output, dir_limit_reached, per_file_counts = self._handle_file(
102
+ search_path,
103
+ pattern,
104
+ regex,
105
+ use_regex,
106
+ case_sensitive,
107
+ max_results,
108
+ total_results,
109
+ count_only,
110
+ )
111
+ else:
112
+ if count_only:
113
+ per_file_counts, dir_limit_reached, _ = traverse_directory(
114
+ search_path,
115
+ pattern,
116
+ regex,
117
+ use_regex,
118
+ case_sensitive,
119
+ max_depth,
120
+ max_results,
121
+ total_results,
122
+ True,
123
+ )
124
+ dir_output = []
125
+ else:
126
+ dir_output, dir_limit_reached, per_file_counts = traverse_directory(
127
+ search_path,
128
+ pattern,
129
+ regex,
130
+ use_regex,
131
+ case_sensitive,
132
+ max_depth,
133
+ max_results,
134
+ total_results,
135
+ False,
136
+ )
137
+ count = sum(count for _, count in per_file_counts)
138
+ file_word = pluralize("match", count)
139
+ num_files = len(per_file_counts)
140
+ file_label = pluralize("file", num_files)
141
+ file_word_max = file_word + (" (max)" if dir_limit_reached else "")
142
+ self.report_success(
143
+ tr(
144
+ " ✅ {count} {file_word} from {num_files} {file_label}",
145
+ count=count,
146
+ file_word=file_word_max,
147
+ num_files=num_files,
148
+ file_label=file_label,
149
+ ),
150
+ ReportAction.READ,
151
+ )
152
+ return info_str, dir_output, dir_limit_reached, per_file_counts
153
+
154
+ def run(
155
+ self,
156
+ paths: str,
157
+ pattern: str,
158
+ is_regex: bool = False,
159
+ case_sensitive: bool = False,
160
+ max_depth: int = 0,
161
+ max_results: int = 100,
162
+ count_only: bool = False,
163
+ ) -> str:
164
+ regex, use_regex, error_msg = prepare_pattern(
165
+ pattern, is_regex, case_sensitive, self.report_error, self.report_warning
166
+ )
167
+ if error_msg:
168
+ return error_msg
169
+ paths_list = paths.split()
170
+ results = []
171
+ all_per_file_counts = []
172
+ for search_path in paths_list:
173
+ info_str, dir_output, dir_limit_reached, per_file_counts = (
174
+ self._handle_path(
175
+ search_path,
176
+ pattern,
177
+ regex,
178
+ use_regex,
179
+ case_sensitive,
180
+ max_depth,
181
+ max_results,
182
+ 0,
183
+ count_only,
184
+ )
185
+ )
186
+ if count_only:
187
+ all_per_file_counts.extend(per_file_counts)
188
+ result_str = format_result(
189
+ pattern,
190
+ use_regex,
191
+ dir_output,
192
+ dir_limit_reached,
193
+ count_only,
194
+ per_file_counts,
195
+ )
196
+ results.append(info_str + "\n" + result_str)
197
+ if dir_limit_reached:
198
+ break
199
+ if count_only:
200
+ results.append(summarize_total(all_per_file_counts))
201
+ return "\n\n".join(results)
@@ -1,73 +1,73 @@
1
- import re
2
- from janito.i18n import tr
3
- from janito.tools.tool_utils import pluralize
4
-
5
-
6
- def prepare_pattern(pattern, is_regex, case_sensitive, report_error, report_warning):
7
- if not pattern:
8
- report_error(
9
- tr("Error: Empty search pattern provided. Operation aborted."),
10
- ReportAction.SEARCH,
11
- )
12
- return (
13
- None,
14
- False,
15
- tr("Error: Empty search pattern provided. Operation aborted."),
16
- )
17
- regex = None
18
- use_regex = False
19
- if is_regex:
20
- try:
21
- flags = 0
22
- if not case_sensitive:
23
- flags |= re.IGNORECASE
24
- regex = re.compile(pattern, flags=flags)
25
- use_regex = True
26
- except re.error as e:
27
- report_warning(tr("⚠️ Invalid regex pattern."))
28
- return (
29
- None,
30
- False,
31
- tr(
32
- "Error: Invalid regex pattern: {error}. Operation aborted.", error=e
33
- ),
34
- )
35
- else:
36
- # Do not compile as regex if is_regex is False; treat as plain text
37
- regex = None
38
- use_regex = False
39
- if not case_sensitive:
40
- pattern = pattern.lower()
41
- return regex, use_regex, None
42
-
43
-
44
- def format_result(
45
- pattern, use_regex, output, limit_reached, count_only=False, per_file_counts=None
46
- ):
47
- # Ensure output is always a list for joining
48
- if output is None or not isinstance(output, (list, tuple)):
49
- output = []
50
- if count_only:
51
- lines = []
52
- total = 0
53
- if per_file_counts:
54
- for file_path, count in per_file_counts:
55
- lines.append(f"{file_path}: {count}")
56
- total += count
57
- lines.append(f"Total matches: {total}")
58
- if limit_reached:
59
- lines.append(tr("[Max results reached. Output truncated.]"))
60
- return "\n".join(lines)
61
- else:
62
- if not output:
63
- return tr("No matches found.")
64
- result = "\n".join(output)
65
- if limit_reached:
66
- result += tr("\n[Max results reached. Output truncated.]")
67
- return result
68
-
69
-
70
- def summarize_total(all_per_file_counts):
71
- total = sum(count for _, count in all_per_file_counts)
72
- summary = f"\nGrand total matches: {total}"
73
- return summary
1
+ import re
2
+ from janito.i18n import tr
3
+ from janito.tools.tool_utils import pluralize
4
+
5
+
6
+ def prepare_pattern(pattern, is_regex, case_sensitive, report_error, report_warning):
7
+ if not pattern:
8
+ report_error(
9
+ tr("Error: Empty search pattern provided. Operation aborted."),
10
+ ReportAction.SEARCH,
11
+ )
12
+ return (
13
+ None,
14
+ False,
15
+ tr("Error: Empty search pattern provided. Operation aborted."),
16
+ )
17
+ regex = None
18
+ use_regex = False
19
+ if is_regex:
20
+ try:
21
+ flags = 0
22
+ if not case_sensitive:
23
+ flags |= re.IGNORECASE
24
+ regex = re.compile(pattern, flags=flags)
25
+ use_regex = True
26
+ except re.error as e:
27
+ report_warning(tr("⚠️ Invalid regex pattern."))
28
+ return (
29
+ None,
30
+ False,
31
+ tr(
32
+ "Error: Invalid regex pattern: {error}. Operation aborted.", error=e
33
+ ),
34
+ )
35
+ else:
36
+ # Do not compile as regex if is_regex is False; treat as plain text
37
+ regex = None
38
+ use_regex = False
39
+ if not case_sensitive:
40
+ pattern = pattern.lower()
41
+ return regex, use_regex, None
42
+
43
+
44
+ def format_result(
45
+ pattern, use_regex, output, limit_reached, count_only=False, per_file_counts=None
46
+ ):
47
+ # Ensure output is always a list for joining
48
+ if output is None or not isinstance(output, (list, tuple)):
49
+ output = []
50
+ if count_only:
51
+ lines = []
52
+ total = 0
53
+ if per_file_counts:
54
+ for file_path, count in per_file_counts:
55
+ lines.append(f"{file_path}: {count}")
56
+ total += count
57
+ lines.append(f"Total matches: {total}")
58
+ if limit_reached:
59
+ lines.append(tr("[Max results reached. Output truncated.]"))
60
+ return "\n".join(lines)
61
+ else:
62
+ if not output:
63
+ return tr("No matches found.")
64
+ result = "\n".join(output)
65
+ if limit_reached:
66
+ result += tr("\n[Max results reached. Output truncated.]")
67
+ return result
68
+
69
+
70
+ def summarize_total(all_per_file_counts):
71
+ total = sum(count for _, count in all_per_file_counts)
72
+ summary = f"\nGrand total matches: {total}"
73
+ return summary