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.
Files changed (134) hide show
  1. doit_cli/__init__.py +1356 -0
  2. doit_cli/cli/__init__.py +26 -0
  3. doit_cli/cli/analytics_command.py +616 -0
  4. doit_cli/cli/context_command.py +213 -0
  5. doit_cli/cli/diagram_command.py +304 -0
  6. doit_cli/cli/fixit_command.py +641 -0
  7. doit_cli/cli/hooks_command.py +211 -0
  8. doit_cli/cli/init_command.py +613 -0
  9. doit_cli/cli/memory_command.py +293 -0
  10. doit_cli/cli/status_command.py +117 -0
  11. doit_cli/cli/sync_prompts_command.py +248 -0
  12. doit_cli/cli/validate_command.py +196 -0
  13. doit_cli/cli/verify_command.py +204 -0
  14. doit_cli/cli/workflow_mixin.py +224 -0
  15. doit_cli/cli/xref_command.py +555 -0
  16. doit_cli/formatters/__init__.py +8 -0
  17. doit_cli/formatters/base.py +38 -0
  18. doit_cli/formatters/json_formatter.py +126 -0
  19. doit_cli/formatters/markdown_formatter.py +97 -0
  20. doit_cli/formatters/rich_formatter.py +257 -0
  21. doit_cli/main.py +49 -0
  22. doit_cli/models/__init__.py +139 -0
  23. doit_cli/models/agent.py +74 -0
  24. doit_cli/models/analytics_models.py +384 -0
  25. doit_cli/models/context_config.py +464 -0
  26. doit_cli/models/crossref_models.py +182 -0
  27. doit_cli/models/diagram_models.py +363 -0
  28. doit_cli/models/fixit_models.py +355 -0
  29. doit_cli/models/hook_config.py +125 -0
  30. doit_cli/models/project.py +91 -0
  31. doit_cli/models/results.py +121 -0
  32. doit_cli/models/search_models.py +228 -0
  33. doit_cli/models/status_models.py +195 -0
  34. doit_cli/models/sync_models.py +146 -0
  35. doit_cli/models/template.py +77 -0
  36. doit_cli/models/validation_models.py +175 -0
  37. doit_cli/models/workflow_models.py +319 -0
  38. doit_cli/prompts/__init__.py +5 -0
  39. doit_cli/prompts/fixit_prompts.py +344 -0
  40. doit_cli/prompts/interactive.py +390 -0
  41. doit_cli/rules/__init__.py +5 -0
  42. doit_cli/rules/builtin_rules.py +160 -0
  43. doit_cli/services/__init__.py +79 -0
  44. doit_cli/services/agent_detector.py +168 -0
  45. doit_cli/services/analytics_service.py +218 -0
  46. doit_cli/services/architecture_generator.py +290 -0
  47. doit_cli/services/backup_service.py +204 -0
  48. doit_cli/services/config_loader.py +113 -0
  49. doit_cli/services/context_loader.py +1121 -0
  50. doit_cli/services/coverage_calculator.py +142 -0
  51. doit_cli/services/crossref_service.py +237 -0
  52. doit_cli/services/cycle_time_calculator.py +134 -0
  53. doit_cli/services/date_inferrer.py +349 -0
  54. doit_cli/services/diagram_service.py +337 -0
  55. doit_cli/services/drift_detector.py +109 -0
  56. doit_cli/services/entity_parser.py +301 -0
  57. doit_cli/services/er_diagram_generator.py +197 -0
  58. doit_cli/services/fixit_service.py +699 -0
  59. doit_cli/services/github_service.py +192 -0
  60. doit_cli/services/hook_manager.py +258 -0
  61. doit_cli/services/hook_validator.py +528 -0
  62. doit_cli/services/input_validator.py +322 -0
  63. doit_cli/services/memory_search.py +527 -0
  64. doit_cli/services/mermaid_validator.py +334 -0
  65. doit_cli/services/prompt_transformer.py +91 -0
  66. doit_cli/services/prompt_writer.py +133 -0
  67. doit_cli/services/query_interpreter.py +428 -0
  68. doit_cli/services/report_exporter.py +219 -0
  69. doit_cli/services/report_generator.py +256 -0
  70. doit_cli/services/requirement_parser.py +112 -0
  71. doit_cli/services/roadmap_summarizer.py +209 -0
  72. doit_cli/services/rule_engine.py +443 -0
  73. doit_cli/services/scaffolder.py +215 -0
  74. doit_cli/services/score_calculator.py +172 -0
  75. doit_cli/services/section_parser.py +204 -0
  76. doit_cli/services/spec_scanner.py +327 -0
  77. doit_cli/services/state_manager.py +355 -0
  78. doit_cli/services/status_reporter.py +143 -0
  79. doit_cli/services/task_parser.py +347 -0
  80. doit_cli/services/template_manager.py +710 -0
  81. doit_cli/services/template_reader.py +158 -0
  82. doit_cli/services/user_journey_generator.py +214 -0
  83. doit_cli/services/user_story_parser.py +232 -0
  84. doit_cli/services/validation_service.py +188 -0
  85. doit_cli/services/validator.py +232 -0
  86. doit_cli/services/velocity_tracker.py +173 -0
  87. doit_cli/services/workflow_engine.py +405 -0
  88. doit_cli/templates/agent-file-template.md +28 -0
  89. doit_cli/templates/checklist-template.md +39 -0
  90. doit_cli/templates/commands/doit.checkin.md +363 -0
  91. doit_cli/templates/commands/doit.constitution.md +187 -0
  92. doit_cli/templates/commands/doit.documentit.md +485 -0
  93. doit_cli/templates/commands/doit.fixit.md +181 -0
  94. doit_cli/templates/commands/doit.implementit.md +265 -0
  95. doit_cli/templates/commands/doit.planit.md +262 -0
  96. doit_cli/templates/commands/doit.reviewit.md +355 -0
  97. doit_cli/templates/commands/doit.roadmapit.md +368 -0
  98. doit_cli/templates/commands/doit.scaffoldit.md +458 -0
  99. doit_cli/templates/commands/doit.specit.md +521 -0
  100. doit_cli/templates/commands/doit.taskit.md +304 -0
  101. doit_cli/templates/commands/doit.testit.md +277 -0
  102. doit_cli/templates/config/context.yaml +134 -0
  103. doit_cli/templates/config/hooks.yaml +93 -0
  104. doit_cli/templates/config/validation-rules.yaml +64 -0
  105. doit_cli/templates/github-issue-templates/epic.yml +78 -0
  106. doit_cli/templates/github-issue-templates/feature.yml +116 -0
  107. doit_cli/templates/github-issue-templates/task.yml +129 -0
  108. doit_cli/templates/hooks/.gitkeep +0 -0
  109. doit_cli/templates/hooks/post-commit.sh +25 -0
  110. doit_cli/templates/hooks/post-merge.sh +75 -0
  111. doit_cli/templates/hooks/pre-commit.sh +17 -0
  112. doit_cli/templates/hooks/pre-push.sh +18 -0
  113. doit_cli/templates/memory/completed_roadmap.md +50 -0
  114. doit_cli/templates/memory/constitution.md +125 -0
  115. doit_cli/templates/memory/roadmap.md +61 -0
  116. doit_cli/templates/plan-template.md +146 -0
  117. doit_cli/templates/scripts/bash/check-prerequisites.sh +166 -0
  118. doit_cli/templates/scripts/bash/common.sh +156 -0
  119. doit_cli/templates/scripts/bash/create-new-feature.sh +297 -0
  120. doit_cli/templates/scripts/bash/setup-plan.sh +61 -0
  121. doit_cli/templates/scripts/bash/update-agent-context.sh +675 -0
  122. doit_cli/templates/scripts/powershell/check-prerequisites.ps1 +148 -0
  123. doit_cli/templates/scripts/powershell/common.ps1 +137 -0
  124. doit_cli/templates/scripts/powershell/create-new-feature.ps1 +283 -0
  125. doit_cli/templates/scripts/powershell/setup-plan.ps1 +61 -0
  126. doit_cli/templates/scripts/powershell/update-agent-context.ps1 +406 -0
  127. doit_cli/templates/spec-template.md +159 -0
  128. doit_cli/templates/tasks-template.md +313 -0
  129. doit_cli/templates/vscode-settings.json +14 -0
  130. doit_toolkit_cli-0.1.9.dist-info/METADATA +324 -0
  131. doit_toolkit_cli-0.1.9.dist-info/RECORD +134 -0
  132. doit_toolkit_cli-0.1.9.dist-info/WHEEL +4 -0
  133. doit_toolkit_cli-0.1.9.dist-info/entry_points.txt +2 -0
  134. 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()