doit-toolkit-cli 0.1.9__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.
- doit_cli/__init__.py +1356 -0
- doit_cli/cli/__init__.py +26 -0
- doit_cli/cli/analytics_command.py +616 -0
- doit_cli/cli/context_command.py +213 -0
- doit_cli/cli/diagram_command.py +304 -0
- doit_cli/cli/fixit_command.py +641 -0
- doit_cli/cli/hooks_command.py +211 -0
- doit_cli/cli/init_command.py +613 -0
- doit_cli/cli/memory_command.py +293 -0
- doit_cli/cli/status_command.py +117 -0
- doit_cli/cli/sync_prompts_command.py +248 -0
- doit_cli/cli/validate_command.py +196 -0
- doit_cli/cli/verify_command.py +204 -0
- doit_cli/cli/workflow_mixin.py +224 -0
- doit_cli/cli/xref_command.py +555 -0
- doit_cli/formatters/__init__.py +8 -0
- doit_cli/formatters/base.py +38 -0
- doit_cli/formatters/json_formatter.py +126 -0
- doit_cli/formatters/markdown_formatter.py +97 -0
- doit_cli/formatters/rich_formatter.py +257 -0
- doit_cli/main.py +49 -0
- doit_cli/models/__init__.py +139 -0
- doit_cli/models/agent.py +74 -0
- doit_cli/models/analytics_models.py +384 -0
- doit_cli/models/context_config.py +464 -0
- doit_cli/models/crossref_models.py +182 -0
- doit_cli/models/diagram_models.py +363 -0
- doit_cli/models/fixit_models.py +355 -0
- doit_cli/models/hook_config.py +125 -0
- doit_cli/models/project.py +91 -0
- doit_cli/models/results.py +121 -0
- doit_cli/models/search_models.py +228 -0
- doit_cli/models/status_models.py +195 -0
- doit_cli/models/sync_models.py +146 -0
- doit_cli/models/template.py +77 -0
- doit_cli/models/validation_models.py +175 -0
- doit_cli/models/workflow_models.py +319 -0
- doit_cli/prompts/__init__.py +5 -0
- doit_cli/prompts/fixit_prompts.py +344 -0
- doit_cli/prompts/interactive.py +390 -0
- doit_cli/rules/__init__.py +5 -0
- doit_cli/rules/builtin_rules.py +160 -0
- doit_cli/services/__init__.py +79 -0
- doit_cli/services/agent_detector.py +168 -0
- doit_cli/services/analytics_service.py +218 -0
- doit_cli/services/architecture_generator.py +290 -0
- doit_cli/services/backup_service.py +204 -0
- doit_cli/services/config_loader.py +113 -0
- doit_cli/services/context_loader.py +1121 -0
- doit_cli/services/coverage_calculator.py +142 -0
- doit_cli/services/crossref_service.py +237 -0
- doit_cli/services/cycle_time_calculator.py +134 -0
- doit_cli/services/date_inferrer.py +349 -0
- doit_cli/services/diagram_service.py +337 -0
- doit_cli/services/drift_detector.py +109 -0
- doit_cli/services/entity_parser.py +301 -0
- doit_cli/services/er_diagram_generator.py +197 -0
- doit_cli/services/fixit_service.py +699 -0
- doit_cli/services/github_service.py +192 -0
- doit_cli/services/hook_manager.py +258 -0
- doit_cli/services/hook_validator.py +528 -0
- doit_cli/services/input_validator.py +322 -0
- doit_cli/services/memory_search.py +527 -0
- doit_cli/services/mermaid_validator.py +334 -0
- doit_cli/services/prompt_transformer.py +91 -0
- doit_cli/services/prompt_writer.py +133 -0
- doit_cli/services/query_interpreter.py +428 -0
- doit_cli/services/report_exporter.py +219 -0
- doit_cli/services/report_generator.py +256 -0
- doit_cli/services/requirement_parser.py +112 -0
- doit_cli/services/roadmap_summarizer.py +209 -0
- doit_cli/services/rule_engine.py +443 -0
- doit_cli/services/scaffolder.py +215 -0
- doit_cli/services/score_calculator.py +172 -0
- doit_cli/services/section_parser.py +204 -0
- doit_cli/services/spec_scanner.py +327 -0
- doit_cli/services/state_manager.py +355 -0
- doit_cli/services/status_reporter.py +143 -0
- doit_cli/services/task_parser.py +347 -0
- doit_cli/services/template_manager.py +710 -0
- doit_cli/services/template_reader.py +158 -0
- doit_cli/services/user_journey_generator.py +214 -0
- doit_cli/services/user_story_parser.py +232 -0
- doit_cli/services/validation_service.py +188 -0
- doit_cli/services/validator.py +232 -0
- doit_cli/services/velocity_tracker.py +173 -0
- doit_cli/services/workflow_engine.py +405 -0
- doit_cli/templates/agent-file-template.md +28 -0
- doit_cli/templates/checklist-template.md +39 -0
- doit_cli/templates/commands/doit.checkin.md +363 -0
- doit_cli/templates/commands/doit.constitution.md +187 -0
- doit_cli/templates/commands/doit.documentit.md +485 -0
- doit_cli/templates/commands/doit.fixit.md +181 -0
- doit_cli/templates/commands/doit.implementit.md +265 -0
- doit_cli/templates/commands/doit.planit.md +262 -0
- doit_cli/templates/commands/doit.reviewit.md +355 -0
- doit_cli/templates/commands/doit.roadmapit.md +368 -0
- doit_cli/templates/commands/doit.scaffoldit.md +458 -0
- doit_cli/templates/commands/doit.specit.md +521 -0
- doit_cli/templates/commands/doit.taskit.md +304 -0
- doit_cli/templates/commands/doit.testit.md +277 -0
- doit_cli/templates/config/context.yaml +134 -0
- doit_cli/templates/config/hooks.yaml +93 -0
- doit_cli/templates/config/validation-rules.yaml +64 -0
- doit_cli/templates/github-issue-templates/epic.yml +78 -0
- doit_cli/templates/github-issue-templates/feature.yml +116 -0
- doit_cli/templates/github-issue-templates/task.yml +129 -0
- doit_cli/templates/hooks/.gitkeep +0 -0
- doit_cli/templates/hooks/post-commit.sh +25 -0
- doit_cli/templates/hooks/post-merge.sh +75 -0
- doit_cli/templates/hooks/pre-commit.sh +17 -0
- doit_cli/templates/hooks/pre-push.sh +18 -0
- doit_cli/templates/memory/completed_roadmap.md +50 -0
- doit_cli/templates/memory/constitution.md +125 -0
- doit_cli/templates/memory/roadmap.md +61 -0
- doit_cli/templates/plan-template.md +146 -0
- doit_cli/templates/scripts/bash/check-prerequisites.sh +166 -0
- doit_cli/templates/scripts/bash/common.sh +156 -0
- doit_cli/templates/scripts/bash/create-new-feature.sh +297 -0
- doit_cli/templates/scripts/bash/setup-plan.sh +61 -0
- doit_cli/templates/scripts/bash/update-agent-context.sh +675 -0
- doit_cli/templates/scripts/powershell/check-prerequisites.ps1 +148 -0
- doit_cli/templates/scripts/powershell/common.ps1 +137 -0
- doit_cli/templates/scripts/powershell/create-new-feature.ps1 +283 -0
- doit_cli/templates/scripts/powershell/setup-plan.ps1 +61 -0
- doit_cli/templates/scripts/powershell/update-agent-context.ps1 +406 -0
- doit_cli/templates/spec-template.md +159 -0
- doit_cli/templates/tasks-template.md +313 -0
- doit_cli/templates/vscode-settings.json +14 -0
- doit_toolkit_cli-0.1.9.dist-info/METADATA +324 -0
- doit_toolkit_cli-0.1.9.dist-info/RECORD +134 -0
- doit_toolkit_cli-0.1.9.dist-info/WHEEL +4 -0
- doit_toolkit_cli-0.1.9.dist-info/entry_points.txt +2 -0
- doit_toolkit_cli-0.1.9.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
"""Memory search service for searching across project context files.
|
|
2
|
+
|
|
3
|
+
This module provides the MemorySearchService for searching constitution,
|
|
4
|
+
roadmap, and spec files with relevance scoring and highlighting.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
|
|
17
|
+
from ..models.search_models import (
|
|
18
|
+
ContentSnippet,
|
|
19
|
+
MemorySource,
|
|
20
|
+
QueryType,
|
|
21
|
+
SearchHistory,
|
|
22
|
+
SearchQuery,
|
|
23
|
+
SearchResult,
|
|
24
|
+
SourceFilter,
|
|
25
|
+
SourceType,
|
|
26
|
+
)
|
|
27
|
+
from .context_loader import ContextLoader, estimate_tokens, extract_keywords
|
|
28
|
+
from .query_interpreter import QueryInterpreter, InterpretedQuery
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MemorySearchService:
|
|
32
|
+
"""Service for searching across project memory files.
|
|
33
|
+
|
|
34
|
+
Provides keyword search, natural language query interpretation,
|
|
35
|
+
and relevance scoring for search results.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
# Section bonuses for relevance scoring
|
|
39
|
+
PRIORITY_SECTIONS = {
|
|
40
|
+
"summary": 1.0,
|
|
41
|
+
"vision": 1.0,
|
|
42
|
+
"overview": 0.8,
|
|
43
|
+
"requirements": 0.5,
|
|
44
|
+
"functional requirements": 0.5,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
def __init__(self, project_root: Path, console: Optional[Console] = None):
|
|
48
|
+
"""Initialize the memory search service.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
project_root: Root directory of the project.
|
|
52
|
+
console: Rich console for output (creates one if not provided).
|
|
53
|
+
"""
|
|
54
|
+
self.project_root = project_root
|
|
55
|
+
self.console = console or Console()
|
|
56
|
+
self.context_loader = ContextLoader(project_root)
|
|
57
|
+
self.query_interpreter = QueryInterpreter()
|
|
58
|
+
self.history = SearchHistory()
|
|
59
|
+
|
|
60
|
+
def _classify_source_type(self, path: Path) -> SourceType:
|
|
61
|
+
"""Classify a file path as governance or spec.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
path: Path to the file.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
SourceType classification.
|
|
68
|
+
"""
|
|
69
|
+
path_str = str(path).lower()
|
|
70
|
+
if ".doit/memory" in path_str:
|
|
71
|
+
return SourceType.GOVERNANCE
|
|
72
|
+
return SourceType.SPEC
|
|
73
|
+
|
|
74
|
+
def _get_files_for_filter(self, source_filter: SourceFilter) -> list[Path]:
|
|
75
|
+
"""Get files matching the source filter.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
source_filter: Filter for source types.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
List of file paths to search.
|
|
82
|
+
"""
|
|
83
|
+
if source_filter == SourceFilter.GOVERNANCE:
|
|
84
|
+
return self.context_loader.get_memory_files()
|
|
85
|
+
elif source_filter == SourceFilter.SPECS:
|
|
86
|
+
return self.context_loader.get_spec_files()
|
|
87
|
+
else: # ALL
|
|
88
|
+
return self.context_loader.get_all_searchable_files()
|
|
89
|
+
|
|
90
|
+
def _find_section_for_line(self, content: str, line_number: int) -> str:
|
|
91
|
+
"""Find which section a line belongs to.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
content: Full file content.
|
|
95
|
+
line_number: Line number to check.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Section name (lowercase) or empty string.
|
|
99
|
+
"""
|
|
100
|
+
lines = content.splitlines()
|
|
101
|
+
current_section = ""
|
|
102
|
+
|
|
103
|
+
for i, line in enumerate(lines[:line_number], 1):
|
|
104
|
+
if line.startswith("## "):
|
|
105
|
+
current_section = line[3:].strip().lower()
|
|
106
|
+
elif line.startswith("# ") and not current_section:
|
|
107
|
+
current_section = line[2:].strip().lower()
|
|
108
|
+
|
|
109
|
+
return current_section
|
|
110
|
+
|
|
111
|
+
def _calculate_relevance_score(
|
|
112
|
+
self,
|
|
113
|
+
content: str,
|
|
114
|
+
query_text: str,
|
|
115
|
+
line_number: int,
|
|
116
|
+
match_count: int,
|
|
117
|
+
total_lines: int,
|
|
118
|
+
) -> float:
|
|
119
|
+
"""Calculate relevance score for a search result.
|
|
120
|
+
|
|
121
|
+
Formula: score = (tf_score * 0.5) + (position_score * 0.3) + (section_bonus * 0.2)
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
content: Full file content.
|
|
125
|
+
query_text: Original search query.
|
|
126
|
+
line_number: Line where match was found.
|
|
127
|
+
match_count: Number of matches in file.
|
|
128
|
+
total_lines: Total lines in file.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Relevance score between 0.0 and 1.0.
|
|
132
|
+
"""
|
|
133
|
+
# Term frequency score (normalized)
|
|
134
|
+
words = len(content.split())
|
|
135
|
+
tf_score = min(1.0, match_count / max(words, 1) * 100)
|
|
136
|
+
|
|
137
|
+
# Position score (earlier is better)
|
|
138
|
+
if line_number <= 10:
|
|
139
|
+
position_score = 1.0
|
|
140
|
+
elif line_number <= 100:
|
|
141
|
+
position_score = 0.5
|
|
142
|
+
else:
|
|
143
|
+
position_score = 0.3
|
|
144
|
+
|
|
145
|
+
# Check if line is in a header
|
|
146
|
+
lines = content.splitlines()
|
|
147
|
+
if line_number <= len(lines):
|
|
148
|
+
line = lines[line_number - 1]
|
|
149
|
+
if line.startswith("#"):
|
|
150
|
+
position_score = 1.0
|
|
151
|
+
|
|
152
|
+
# Section bonus
|
|
153
|
+
section = self._find_section_for_line(content, line_number)
|
|
154
|
+
section_bonus = 0.0
|
|
155
|
+
for key, bonus in self.PRIORITY_SECTIONS.items():
|
|
156
|
+
if key in section:
|
|
157
|
+
section_bonus = bonus
|
|
158
|
+
break
|
|
159
|
+
|
|
160
|
+
# Calculate final score
|
|
161
|
+
score = (tf_score * 0.5) + (position_score * 0.3) + (section_bonus * 0.2)
|
|
162
|
+
return min(1.0, max(0.0, score))
|
|
163
|
+
|
|
164
|
+
def _extract_context(
|
|
165
|
+
self, content: str, line_number: int, context_lines: int = 2
|
|
166
|
+
) -> tuple[str, str, str]:
|
|
167
|
+
"""Extract context around a matched line.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
content: Full file content.
|
|
171
|
+
line_number: Line where match was found (1-indexed).
|
|
172
|
+
context_lines: Number of context lines before/after.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Tuple of (context_before, matched_line, context_after).
|
|
176
|
+
"""
|
|
177
|
+
lines = content.splitlines()
|
|
178
|
+
idx = line_number - 1
|
|
179
|
+
|
|
180
|
+
if idx < 0 or idx >= len(lines):
|
|
181
|
+
return "", "", ""
|
|
182
|
+
|
|
183
|
+
matched_line = lines[idx]
|
|
184
|
+
|
|
185
|
+
start_idx = max(0, idx - context_lines)
|
|
186
|
+
end_idx = min(len(lines), idx + context_lines + 1)
|
|
187
|
+
|
|
188
|
+
context_before = "\n".join(lines[start_idx:idx])
|
|
189
|
+
context_after = "\n".join(lines[idx + 1 : end_idx])
|
|
190
|
+
|
|
191
|
+
return context_before, matched_line, context_after
|
|
192
|
+
|
|
193
|
+
def search_keyword(
|
|
194
|
+
self, query: SearchQuery
|
|
195
|
+
) -> tuple[list[SearchResult], list[MemorySource]]:
|
|
196
|
+
"""Search for keywords across memory files.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
query: The search query with parameters.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Tuple of (list of search results, list of memory sources).
|
|
203
|
+
"""
|
|
204
|
+
results: list[SearchResult] = []
|
|
205
|
+
sources: dict[str, MemorySource] = {}
|
|
206
|
+
|
|
207
|
+
# Get files to search
|
|
208
|
+
files = self._get_files_for_filter(query.source_filter)
|
|
209
|
+
|
|
210
|
+
if not files:
|
|
211
|
+
return results, list(sources.values())
|
|
212
|
+
|
|
213
|
+
# Prepare search pattern
|
|
214
|
+
pattern = query.query_text
|
|
215
|
+
if query.query_type == QueryType.PHRASE:
|
|
216
|
+
pattern = re.escape(pattern)
|
|
217
|
+
elif query.query_type != QueryType.REGEX and not query.use_regex:
|
|
218
|
+
# Escape special characters for keyword search
|
|
219
|
+
pattern = re.escape(pattern)
|
|
220
|
+
|
|
221
|
+
flags = 0 if query.case_sensitive else re.IGNORECASE
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
regex = re.compile(pattern, flags)
|
|
225
|
+
except re.error as e:
|
|
226
|
+
raise ValueError(f"Invalid regex pattern: {e}")
|
|
227
|
+
|
|
228
|
+
# Search each file
|
|
229
|
+
for file_path in files:
|
|
230
|
+
try:
|
|
231
|
+
content = file_path.read_text(encoding="utf-8")
|
|
232
|
+
except (OSError, UnicodeDecodeError):
|
|
233
|
+
continue
|
|
234
|
+
|
|
235
|
+
lines = content.splitlines()
|
|
236
|
+
source_type = self._classify_source_type(file_path)
|
|
237
|
+
|
|
238
|
+
# Create memory source
|
|
239
|
+
source = MemorySource.from_path(file_path, source_type)
|
|
240
|
+
sources[source.id] = source
|
|
241
|
+
|
|
242
|
+
# Find matches
|
|
243
|
+
file_matches = []
|
|
244
|
+
for i, line in enumerate(lines, 1):
|
|
245
|
+
matches = list(regex.finditer(line))
|
|
246
|
+
if matches:
|
|
247
|
+
for match in matches:
|
|
248
|
+
file_matches.append((i, match.group(), match.start(), match.end()))
|
|
249
|
+
|
|
250
|
+
if not file_matches:
|
|
251
|
+
continue
|
|
252
|
+
|
|
253
|
+
# Calculate relevance for each match
|
|
254
|
+
match_count = len(file_matches)
|
|
255
|
+
for line_number, matched_text, start, end in file_matches:
|
|
256
|
+
relevance = self._calculate_relevance_score(
|
|
257
|
+
content, query.query_text, line_number, match_count, len(lines)
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
context_before, matched_line, context_after = self._extract_context(
|
|
261
|
+
content, line_number
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
result = SearchResult(
|
|
265
|
+
query_id=query.id,
|
|
266
|
+
source_id=source.id,
|
|
267
|
+
relevance_score=relevance,
|
|
268
|
+
line_number=line_number,
|
|
269
|
+
matched_text=matched_text,
|
|
270
|
+
context_before=context_before,
|
|
271
|
+
context_after=context_after,
|
|
272
|
+
)
|
|
273
|
+
results.append(result)
|
|
274
|
+
|
|
275
|
+
# Sort by relevance and limit results
|
|
276
|
+
results.sort(key=lambda r: r.relevance_score, reverse=True)
|
|
277
|
+
results = results[: query.max_results]
|
|
278
|
+
|
|
279
|
+
# Track in history
|
|
280
|
+
self.history.add_query(query)
|
|
281
|
+
|
|
282
|
+
return results, list(sources.values())
|
|
283
|
+
|
|
284
|
+
def search_natural(
|
|
285
|
+
self, query: SearchQuery
|
|
286
|
+
) -> tuple[list[SearchResult], list[MemorySource], InterpretedQuery]:
|
|
287
|
+
"""Search using natural language query interpretation.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
query: The search query with natural language text.
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
Tuple of (list of search results, list of memory sources, interpreted query).
|
|
294
|
+
"""
|
|
295
|
+
# Interpret the natural language query
|
|
296
|
+
interpreted = self.query_interpreter.interpret(query.query_text)
|
|
297
|
+
|
|
298
|
+
# Get search terms from interpretation
|
|
299
|
+
search_terms = interpreted.search_terms if interpreted.search_terms else interpreted.keywords
|
|
300
|
+
|
|
301
|
+
if not search_terms:
|
|
302
|
+
# Fall back to original query if no keywords extracted
|
|
303
|
+
search_terms = [query.query_text]
|
|
304
|
+
|
|
305
|
+
# Build a regex pattern that matches ANY of the search terms (OR)
|
|
306
|
+
# This gives more flexible matching than an exact phrase search
|
|
307
|
+
escaped_terms = [re.escape(term) for term in search_terms]
|
|
308
|
+
search_pattern = "|".join(escaped_terms)
|
|
309
|
+
|
|
310
|
+
# Create a regex query with interpreted terms
|
|
311
|
+
keyword_query = SearchQuery(
|
|
312
|
+
query_text=search_pattern,
|
|
313
|
+
query_type=QueryType.REGEX, # Use regex for OR matching
|
|
314
|
+
source_filter=query.source_filter,
|
|
315
|
+
max_results=query.max_results * 2, # Get more results for relevance filtering
|
|
316
|
+
case_sensitive=query.case_sensitive,
|
|
317
|
+
use_regex=True,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
# Execute keyword search
|
|
321
|
+
results, sources = self.search_keyword(keyword_query)
|
|
322
|
+
|
|
323
|
+
# Boost results that match section hints
|
|
324
|
+
if interpreted.section_hints:
|
|
325
|
+
for result in results:
|
|
326
|
+
source = next((s for s in sources if s.id == result.source_id), None)
|
|
327
|
+
if source:
|
|
328
|
+
try:
|
|
329
|
+
content = source.file_path.read_text(encoding="utf-8")
|
|
330
|
+
section = self._find_section_for_line(content, result.line_number)
|
|
331
|
+
for hint in interpreted.section_hints:
|
|
332
|
+
if hint.lower() in section:
|
|
333
|
+
# Boost score by 10%
|
|
334
|
+
result.relevance_score = min(1.0, result.relevance_score * 1.1)
|
|
335
|
+
break
|
|
336
|
+
except (OSError, UnicodeDecodeError):
|
|
337
|
+
pass
|
|
338
|
+
|
|
339
|
+
# Re-sort after boosting
|
|
340
|
+
results.sort(key=lambda r: r.relevance_score, reverse=True)
|
|
341
|
+
|
|
342
|
+
# Limit to original max_results
|
|
343
|
+
results = results[: query.max_results]
|
|
344
|
+
|
|
345
|
+
# Track the original natural language query in history
|
|
346
|
+
self.history.add_query(query)
|
|
347
|
+
|
|
348
|
+
return results, sources, interpreted
|
|
349
|
+
|
|
350
|
+
def search(
|
|
351
|
+
self,
|
|
352
|
+
query_text: str,
|
|
353
|
+
query_type: QueryType = QueryType.KEYWORD,
|
|
354
|
+
source_filter: SourceFilter = SourceFilter.ALL,
|
|
355
|
+
max_results: int = 20,
|
|
356
|
+
case_sensitive: bool = False,
|
|
357
|
+
use_regex: bool = False,
|
|
358
|
+
) -> tuple[list[SearchResult], list[MemorySource], SearchQuery]:
|
|
359
|
+
"""Convenience method to search with individual parameters.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
query_text: The search term or question.
|
|
363
|
+
query_type: Type of query (keyword, phrase, natural, regex).
|
|
364
|
+
source_filter: Filter for source types.
|
|
365
|
+
max_results: Maximum results to return.
|
|
366
|
+
case_sensitive: Enable case-sensitive matching.
|
|
367
|
+
use_regex: Interpret query as regex.
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
Tuple of (results, sources, query).
|
|
371
|
+
"""
|
|
372
|
+
query = SearchQuery(
|
|
373
|
+
query_text=query_text,
|
|
374
|
+
query_type=query_type,
|
|
375
|
+
source_filter=source_filter,
|
|
376
|
+
max_results=max_results,
|
|
377
|
+
case_sensitive=case_sensitive,
|
|
378
|
+
use_regex=use_regex,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
# Use natural language processing for NATURAL query type
|
|
382
|
+
if query_type == QueryType.NATURAL:
|
|
383
|
+
results, sources, interpreted = self.search_natural(query)
|
|
384
|
+
# Store interpreted query info (could be used for display)
|
|
385
|
+
query._interpreted = interpreted
|
|
386
|
+
else:
|
|
387
|
+
results, sources = self.search_keyword(query)
|
|
388
|
+
|
|
389
|
+
return results, sources, query
|
|
390
|
+
|
|
391
|
+
def format_result_rich(
|
|
392
|
+
self,
|
|
393
|
+
result: SearchResult,
|
|
394
|
+
sources: dict[str, MemorySource],
|
|
395
|
+
query_text: str,
|
|
396
|
+
) -> Panel:
|
|
397
|
+
"""Format a search result as a Rich panel.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
result: The search result to format.
|
|
401
|
+
sources: Dictionary of source ID to MemorySource.
|
|
402
|
+
query_text: Original query text for highlighting.
|
|
403
|
+
|
|
404
|
+
Returns:
|
|
405
|
+
Rich Panel containing the formatted result.
|
|
406
|
+
"""
|
|
407
|
+
source = sources.get(result.source_id)
|
|
408
|
+
if not source:
|
|
409
|
+
return Panel("Unknown source")
|
|
410
|
+
|
|
411
|
+
# Build the content with highlighting
|
|
412
|
+
text = Text()
|
|
413
|
+
|
|
414
|
+
# Add context before
|
|
415
|
+
if result.context_before:
|
|
416
|
+
text.append(result.context_before + "\n", style="dim")
|
|
417
|
+
|
|
418
|
+
# Add matched line with highlighting
|
|
419
|
+
line_content = result.context_before.split("\n")[-1] if result.context_before else ""
|
|
420
|
+
line_content = ""
|
|
421
|
+
|
|
422
|
+
# Get the full matched line
|
|
423
|
+
try:
|
|
424
|
+
content = source.file_path.read_text(encoding="utf-8")
|
|
425
|
+
lines = content.splitlines()
|
|
426
|
+
if 0 < result.line_number <= len(lines):
|
|
427
|
+
line_content = lines[result.line_number - 1]
|
|
428
|
+
except (OSError, UnicodeDecodeError):
|
|
429
|
+
line_content = result.matched_text
|
|
430
|
+
|
|
431
|
+
# Highlight matches in the line
|
|
432
|
+
highlighted = Text(line_content)
|
|
433
|
+
try:
|
|
434
|
+
pattern = re.compile(re.escape(query_text), re.IGNORECASE)
|
|
435
|
+
for match in pattern.finditer(line_content):
|
|
436
|
+
highlighted.stylize("bold yellow", match.start(), match.end())
|
|
437
|
+
except re.error:
|
|
438
|
+
pass
|
|
439
|
+
|
|
440
|
+
text.append(highlighted)
|
|
441
|
+
text.append("\n")
|
|
442
|
+
|
|
443
|
+
# Add context after
|
|
444
|
+
if result.context_after:
|
|
445
|
+
text.append(result.context_after, style="dim")
|
|
446
|
+
|
|
447
|
+
# Build title
|
|
448
|
+
rel_path = source.file_path.relative_to(self.project_root)
|
|
449
|
+
title = f"📄 {rel_path}:{result.line_number}"
|
|
450
|
+
subtitle = f"Score: {result.relevance_score:.2f}"
|
|
451
|
+
|
|
452
|
+
return Panel(
|
|
453
|
+
text,
|
|
454
|
+
title=title,
|
|
455
|
+
subtitle=subtitle,
|
|
456
|
+
border_style="blue",
|
|
457
|
+
padding=(0, 1),
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
def format_results_json(
|
|
461
|
+
self,
|
|
462
|
+
results: list[SearchResult],
|
|
463
|
+
sources: list[MemorySource],
|
|
464
|
+
query: SearchQuery,
|
|
465
|
+
execution_time_ms: int,
|
|
466
|
+
) -> dict:
|
|
467
|
+
"""Format search results as JSON.
|
|
468
|
+
|
|
469
|
+
Args:
|
|
470
|
+
results: List of search results.
|
|
471
|
+
sources: List of memory sources.
|
|
472
|
+
query: The search query.
|
|
473
|
+
execution_time_ms: Search execution time in milliseconds.
|
|
474
|
+
|
|
475
|
+
Returns:
|
|
476
|
+
Dictionary suitable for JSON serialization.
|
|
477
|
+
"""
|
|
478
|
+
source_map = {s.id: s for s in sources}
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
"query": {
|
|
482
|
+
"id": query.id,
|
|
483
|
+
"text": query.query_text,
|
|
484
|
+
"type": query.query_type.value,
|
|
485
|
+
"source_filter": query.source_filter.value,
|
|
486
|
+
"case_sensitive": query.case_sensitive,
|
|
487
|
+
},
|
|
488
|
+
"results": [
|
|
489
|
+
{
|
|
490
|
+
"id": r.id,
|
|
491
|
+
"source": {
|
|
492
|
+
"path": str(source_map[r.source_id].file_path.relative_to(self.project_root))
|
|
493
|
+
if r.source_id in source_map
|
|
494
|
+
else "unknown",
|
|
495
|
+
"type": source_map[r.source_id].source_type.value
|
|
496
|
+
if r.source_id in source_map
|
|
497
|
+
else "unknown",
|
|
498
|
+
},
|
|
499
|
+
"relevance_score": r.relevance_score,
|
|
500
|
+
"line_number": r.line_number,
|
|
501
|
+
"matched_text": r.matched_text,
|
|
502
|
+
"context": {
|
|
503
|
+
"before": r.context_before,
|
|
504
|
+
"match": r.matched_text,
|
|
505
|
+
"after": r.context_after,
|
|
506
|
+
},
|
|
507
|
+
}
|
|
508
|
+
for r in results
|
|
509
|
+
],
|
|
510
|
+
"metadata": {
|
|
511
|
+
"total_results": len(results),
|
|
512
|
+
"files_searched": len(sources),
|
|
513
|
+
"execution_time_ms": execution_time_ms,
|
|
514
|
+
},
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
def get_history(self) -> SearchHistory:
|
|
518
|
+
"""Get the search history.
|
|
519
|
+
|
|
520
|
+
Returns:
|
|
521
|
+
The current search history.
|
|
522
|
+
"""
|
|
523
|
+
return self.history
|
|
524
|
+
|
|
525
|
+
def clear_history(self) -> None:
|
|
526
|
+
"""Clear the search history."""
|
|
527
|
+
self.history.clear()
|