flash-context 0.3.0__tar.gz → 0.3.2__tar.gz

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 (21) hide show
  1. {flash_context-0.3.0 → flash_context-0.3.2}/CHANGELOG.md +44 -0
  2. {flash_context-0.3.0 → flash_context-0.3.2}/PKG-INFO +10 -14
  3. {flash_context-0.3.0 → flash_context-0.3.2}/README.md +10 -14
  4. {flash_context-0.3.0 → flash_context-0.3.2}/pyproject.toml +1 -1
  5. {flash_context-0.3.0 → flash_context-0.3.2}/src/flash_context/agent.py +229 -37
  6. {flash_context-0.3.0 → flash_context-0.3.2}/src/flash_context/mcp_server.py +3 -0
  7. {flash_context-0.3.0 → flash_context-0.3.2}/.env.example +0 -0
  8. {flash_context-0.3.0 → flash_context-0.3.2}/.gitignore +0 -0
  9. {flash_context-0.3.0 → flash_context-0.3.2}/LICENSE +0 -0
  10. {flash_context-0.3.0 → flash_context-0.3.2}/config.yaml.example +0 -0
  11. {flash_context-0.3.0 → flash_context-0.3.2}/src/flash_context/__init__.py +0 -0
  12. {flash_context-0.3.0 → flash_context-0.3.2}/src/flash_context/cli.py +0 -0
  13. {flash_context-0.3.0 → flash_context-0.3.2}/src/flash_context/config.py +0 -0
  14. {flash_context-0.3.0 → flash_context-0.3.2}/src/flash_context/llm.py +0 -0
  15. {flash_context-0.3.0 → flash_context-0.3.2}/src/flash_context/logging.py +0 -0
  16. {flash_context-0.3.0 → flash_context-0.3.2}/src/flash_context/tools/__init__.py +0 -0
  17. {flash_context-0.3.0 → flash_context-0.3.2}/src/flash_context/tools/glob.py +0 -0
  18. {flash_context-0.3.0 → flash_context-0.3.2}/src/flash_context/tools/grep.py +0 -0
  19. {flash_context-0.3.0 → flash_context-0.3.2}/src/flash_context/tools/read.py +0 -0
  20. {flash_context-0.3.0 → flash_context-0.3.2}/src/flash_context/tools/safety.py +0 -0
  21. {flash_context-0.3.0 → flash_context-0.3.2}/src/flash_context/types.py +0 -0
@@ -4,6 +4,50 @@ User-visible changes and migration instructions for each release.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.3.2] - 2026-11-01
8
+
9
+ ### Fixed
10
+
11
+ - Require whole, case-sensitive query tokens inside exact-symbol citations,
12
+ including read-derived fallback evidence. Reject plausible-home file citations
13
+ without matching source text and retain scoped Python preflight diagnostics.
14
+ - Report `query symbol not found in verified evidence` when symbol lookups finish
15
+ without accepted evidence, rather than relying on generic termination warnings.
16
+
17
+ ### Migration
18
+
19
+ - Exact-symbol queries may now return `NO_CITATIONS_FOUND` with empty citations
20
+ and snippets where previous versions returned unrelated files. Token presence
21
+ is lexical evidence, not proof of a definition or semantic relevance. Public
22
+ schemas, configuration, and CLI exit semantics are unchanged.
23
+
24
+ ### Changed
25
+
26
+ - Condense the README overview and move safety and evidence-validation details
27
+ to the architecture documentation.
28
+
29
+ ## [0.3.1] - 2026-11-01
30
+
31
+ ### Fixed
32
+
33
+ - Report the flash-context package version during MCP initialization instead of
34
+ the MCP SDK version.
35
+ - Reuse repeated tool observations without discarding fresh searches in the same
36
+ batch; execute same-turn duplicates only once and stop all-repeat stalls.
37
+ - Continue implementation searches after documentation-only citations within the
38
+ existing turn budget. Documentation-only fallbacks retain citations and snippets
39
+ with explicit incomplete-result metadata and warnings.
40
+
41
+ ### Changed
42
+
43
+ - Describe deterministic lookup and delegated model search together in the README.
44
+
45
+ ### Migration
46
+
47
+ - Implementation questions may use additional turns before returning documentation
48
+ evidence. These fallbacks set `truncated: true`; partial cited CLI results still
49
+ exit 0. Budgets, configuration keys, and result schemas are unchanged.
50
+
7
51
  ## [0.3.0] - 2026-11-01
8
52
 
9
53
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: flash-context
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Read-only context exploration and retrieval for AI agents, starting with codebases and repositories.
5
5
  Project-URL: Repository, https://github.com/zyf0717/flash-context
6
6
  Project-URL: Issues, https://github.com/zyf0717/flash-context/issues
@@ -28,20 +28,16 @@ Description-Content-Type: text/markdown
28
28
  [![Python versions](https://img.shields.io/pypi/pyversions/flash-context)](https://pypi.org/project/flash-context/)
29
29
  [![License](https://img.shields.io/pypi/l/flash-context)](LICENSE)
30
30
 
31
- Read-only context exploration and retrieval for AI agents,
32
- available as a Python CLI and Model Context Protocol (MCP) server.
31
+ A lightweight, model-agnostic repository explorer for AI agents, available as a
32
+ Python CLI and Model Context Protocol (MCP) server. It combines deterministic
33
+ lookup with search and reading delegated to a utility model through an
34
+ OpenAI-compatible endpoint, returning compact, validated file-line citations and
35
+ source snippets.
33
36
 
34
- Delegate recursive, repetitive file search and reading to a utility model
35
- behind an OpenAI-compatible endpoint. The calling agent receives validated
36
- file-line citations and source snippets to use in its main task.
37
-
38
- Codebases and repositories are the current focus; the same tools explore
39
- documentation, configuration, and other local text files. CLI and MCP share one
40
- exploration core with read-only tools, path safety, and resource limits.
41
- Scans and regex execution are bounded; partial results report truncation.
42
- Citation validation checks safe paths and existing line coverage, not semantic
43
- relevance or minimal ranges. Citation precision depends on the explorer model.
44
- Optional metadata-only trajectory logs must stay outside the inspected repository.
37
+ CLI and MCP share a read-only core for exploring code, documentation,
38
+ configuration, and other local text files with path safety and resource limits.
39
+ See [safety and evidence validation](docs/architecture.md#safety-and-evidence-validation)
40
+ for details and limitations.
45
41
 
46
42
  Inspired by [FastContext](https://arxiv.org/abs/2606.14066), a now-withdrawn paper
47
43
  on separating repository exploration from task solving, using specialized models
@@ -5,20 +5,16 @@
5
5
  [![Python versions](https://img.shields.io/pypi/pyversions/flash-context)](https://pypi.org/project/flash-context/)
6
6
  [![License](https://img.shields.io/pypi/l/flash-context)](LICENSE)
7
7
 
8
- Read-only context exploration and retrieval for AI agents,
9
- available as a Python CLI and Model Context Protocol (MCP) server.
10
-
11
- Delegate recursive, repetitive file search and reading to a utility model
12
- behind an OpenAI-compatible endpoint. The calling agent receives validated
13
- file-line citations and source snippets to use in its main task.
14
-
15
- Codebases and repositories are the current focus; the same tools explore
16
- documentation, configuration, and other local text files. CLI and MCP share one
17
- exploration core with read-only tools, path safety, and resource limits.
18
- Scans and regex execution are bounded; partial results report truncation.
19
- Citation validation checks safe paths and existing line coverage, not semantic
20
- relevance or minimal ranges. Citation precision depends on the explorer model.
21
- Optional metadata-only trajectory logs must stay outside the inspected repository.
8
+ A lightweight, model-agnostic repository explorer for AI agents, available as a
9
+ Python CLI and Model Context Protocol (MCP) server. It combines deterministic
10
+ lookup with search and reading delegated to a utility model through an
11
+ OpenAI-compatible endpoint, returning compact, validated file-line citations and
12
+ source snippets.
13
+
14
+ CLI and MCP share a read-only core for exploring code, documentation,
15
+ configuration, and other local text files with path safety and resource limits.
16
+ See [safety and evidence validation](docs/architecture.md#safety-and-evidence-validation)
17
+ for details and limitations.
22
18
 
23
19
  Inspired by [FastContext](https://arxiv.org/abs/2606.14066), a now-withdrawn paper
24
20
  on separating repository exploration from task solving, using specialized models
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flash-context"
3
- version = "0.3.0"
3
+ version = "0.3.2"
4
4
  description = "Read-only context exploration and retrieval for AI agents, starting with codebases and repositories."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  import json
4
4
  import re
5
5
  from concurrent.futures import ThreadPoolExecutor
6
- from dataclasses import dataclass, field
6
+ from dataclasses import dataclass, field, replace
7
7
  from pathlib import Path
8
8
  from typing import Any, Literal, cast
9
9
 
@@ -62,11 +62,21 @@ EXACT_TEXT_RE = re.compile(r"[`'\"](?P<target>[^`'\"]{1,120})[`'\"]")
62
62
  TOKEN_RE = re.compile(r"\b(?P<token>[A-Za-z_][A-Za-z0-9_]*)\b")
63
63
 
64
64
 
65
+ @dataclass(frozen=True, slots=True)
66
+ class _DefinitionScan:
67
+ citations: tuple[Citation, ...]
68
+ status: Literal["unique", "missing", "ambiguous", "incomplete"]
69
+ warnings: tuple[str, ...]
70
+
71
+
65
72
  @dataclass(slots=True)
66
73
  class EvidenceState:
67
74
  reads: list[ToolObservation] = field(default_factory=list)
68
75
  citations: list[Citation] = field(default_factory=list)
69
- seen_tool_calls: set[str] = field(default_factory=set)
76
+ observations: dict[str, ToolObservation] = field(default_factory=dict)
77
+ prefer_source: bool = False
78
+ symbol: str | None = None
79
+ definition_scan: _DefinitionScan | None = None
70
80
 
71
81
 
72
82
  def explore(
@@ -78,10 +88,15 @@ def explore(
78
88
  request.validate()
79
89
  repo_root = request.repo_root.resolve()
80
90
  validate_trajectory_dir(settings.traj_dir, repo_root)
91
+ evidence = EvidenceState(
92
+ prefer_source=_implementation_query(request.query),
93
+ symbol=_query_symbol(request.query),
94
+ )
81
95
  fast_result = _try_exact_fast_path(
82
96
  request,
83
97
  repo_root=repo_root,
84
98
  settings=settings,
99
+ evidence=evidence,
85
100
  )
86
101
  if fast_result is not None:
87
102
  return fast_result
@@ -98,12 +113,28 @@ def explore(
98
113
  limits=settings.limits,
99
114
  )
100
115
  messages = _initial_messages(request, file_sample, settings)
116
+ if evidence.definition_scan is not None:
117
+ scan = evidence.definition_scan
118
+ messages.append(
119
+ {
120
+ "role": "system",
121
+ "content": (
122
+ f"Local Python .py/.pyi definition scan for {evidence.symbol}: "
123
+ f"{scan.status}. "
124
+ "A complete missing result means zero Python definition matches; "
125
+ "assignments, imports, docs, and other languages may still "
126
+ "contain the symbol. Never infer a plausible home file. "
127
+ "Cited lines must contain the exact case-sensitive whole symbol."
128
+ ),
129
+ }
130
+ )
101
131
  tools = tool_schemas()
102
132
  # A capped file sample is expected; actual scan failures must reach callers.
103
133
  warnings = list(file_sample.warnings)
134
+ if evidence.definition_scan is not None:
135
+ warnings.extend(evidence.definition_scan.warnings)
104
136
  truncated = bool(warnings)
105
137
  last_content = ""
106
- evidence = EvidenceState()
107
138
 
108
139
  try:
109
140
  for turn in range(1, effective_max_turns + 1):
@@ -145,6 +176,7 @@ def explore(
145
176
  recorder.finish(result=result)
146
177
  return result
147
178
  recorder.record_model_turn(turn, message)
179
+ needs_source = False
148
180
  content_text = str(message.get("content") or "").strip()
149
181
  if content_text:
150
182
  last_content = content_text
@@ -157,6 +189,12 @@ def explore(
157
189
  )
158
190
  if citations:
159
191
  evidence.citations = citations
192
+ needs_source = evidence.prefer_source and _documentation_only(
193
+ citations
194
+ )
195
+ if needs_source:
196
+ last_content = ""
197
+ if citations and not needs_source:
160
198
  if _message_has_tool_calls(message):
161
199
  _append_warning_once(
162
200
  warnings,
@@ -178,6 +216,27 @@ def explore(
178
216
  tool_calls = _extract_tool_calls(message)
179
217
  if not tool_calls:
180
218
  citations = _best_current_citations(evidence)
219
+ if evidence.symbol and content_text == NO_CITATIONS_FOUND:
220
+ citations = []
221
+ elif evidence.symbol and not citations and turn < effective_max_turns:
222
+ messages.append({"role": "assistant", "content": content_text})
223
+ messages.append(
224
+ {
225
+ "role": "user",
226
+ "content": (
227
+ "No verified citation contains the exact query symbol. "
228
+ "Search with fresh read-only calls, or return "
229
+ "NO_CITATIONS_FOUND."
230
+ ),
231
+ }
232
+ )
233
+ last_content = ""
234
+ continue
235
+ if evidence.prefer_source and _documentation_only(citations):
236
+ last_content = ""
237
+ messages.append({"role": "assistant", "content": content_text})
238
+ messages.append(_source_feedback())
239
+ continue
181
240
  if request.citation and content_text != NO_CITATIONS_FOUND:
182
241
  _append_warning_once(
183
242
  warnings,
@@ -187,7 +246,7 @@ def explore(
187
246
  request=request,
188
247
  repo_root=repo_root,
189
248
  settings=settings,
190
- answer=content_text,
249
+ answer="" if needs_source else content_text,
191
250
  citations=citations,
192
251
  turns_used=turn,
193
252
  truncated=truncated,
@@ -219,8 +278,12 @@ def explore(
219
278
  recorder.finish(result=result)
220
279
  return result
221
280
 
222
- repeated_call = _first_repeated_tool_call(evidence, tool_calls)
223
- if repeated_call is not None:
281
+ fresh_calls: dict[str, ToolCall] = {}
282
+ for tool_call in tool_calls:
283
+ fingerprint = _tool_call_fingerprint(tool_call)
284
+ if fingerprint not in evidence.observations:
285
+ fresh_calls.setdefault(fingerprint, tool_call)
286
+ if not fresh_calls:
224
287
  _append_warning_once(
225
288
  warnings,
226
289
  "repeated tool call stopped exploration",
@@ -238,17 +301,18 @@ def explore(
238
301
  )
239
302
  recorder.finish(result=result)
240
303
  return result
241
- for tool_call in tool_calls:
242
- evidence.seen_tool_calls.add(_tool_call_fingerprint(tool_call))
243
-
244
304
  messages.append(_assistant_tool_message(message))
245
305
  observations = _execute_tool_calls_parallel(
246
- tool_calls,
306
+ list(fresh_calls.values()),
247
307
  repo_root=repo_root,
248
308
  settings=settings,
249
309
  )
250
- for tool_call, observation in zip(tool_calls, observations, strict=True):
251
- observation.tool_call_id = tool_call.id
310
+ evidence.observations.update(zip(fresh_calls, observations, strict=True))
311
+ for tool_call in tool_calls:
312
+ observation = replace(
313
+ evidence.observations[_tool_call_fingerprint(tool_call)],
314
+ tool_call_id=tool_call.id,
315
+ )
252
316
  payload = _model_observation_payload(
253
317
  observation,
254
318
  max_chars=settings.max_observation_chars,
@@ -262,7 +326,9 @@ def explore(
262
326
  and observation.path
263
327
  and observation.line_range
264
328
  ):
265
- evidence.reads.append(observation)
329
+ read = replace(observation, tool_call_id=None)
330
+ if read not in evidence.reads:
331
+ evidence.reads.append(read)
266
332
  recorder.record_observation(turn, tool_call.name, observation)
267
333
  messages.append(
268
334
  {
@@ -275,6 +341,8 @@ def explore(
275
341
  ),
276
342
  }
277
343
  )
344
+ if needs_source:
345
+ messages.append(_source_feedback())
278
346
  if last_content:
279
347
  citations = _validated_citations_from_content(
280
348
  last_content,
@@ -286,6 +354,8 @@ def explore(
286
354
  last_content,
287
355
  evidence.reads,
288
356
  )
357
+ if evidence.symbol:
358
+ citations = _best_current_citations(evidence)
289
359
  _append_warning_once(warnings, "max turns exceeded after partial answer")
290
360
  if request.citation and not citations:
291
361
  _append_warning_once(
@@ -417,7 +487,17 @@ def _finalize_result(
417
487
  truncated: bool,
418
488
  warnings: list[str],
419
489
  ) -> ExploreResult:
490
+ if _query_symbol(request.query) and not citations:
491
+ answer = NO_CITATIONS_FOUND
492
+ _append_warning_once(warnings, "query symbol not found in verified evidence")
420
493
  citations = _merge_adjacent_citations(citations)[:MAX_FINAL_CITATIONS]
494
+ if _implementation_query(request.query) and _documentation_only(citations):
495
+ truncated = True
496
+ answer = _citation_text(citations)
497
+ _append_warning_once(
498
+ warnings,
499
+ "implementation source not verified; returning documentation evidence",
500
+ )
421
501
  if request.citation or (not answer and citations):
422
502
  answer = _citation_text(citations)
423
503
  raw_locations = _extract_raw_locations(
@@ -551,6 +631,7 @@ def _try_exact_fast_path(
551
631
  *,
552
632
  repo_root: Path,
553
633
  settings: Settings,
634
+ evidence: EvidenceState,
554
635
  ) -> ExploreResult | None:
555
636
  bare_path = request.query.strip()
556
637
  if len(bare_path) >= 2 and bare_path[0] in "`'\"" and bare_path[-1] == bare_path[0]:
@@ -581,7 +662,6 @@ def _try_exact_fast_path(
581
662
  )
582
663
  if not _is_exact_lookup(request.query):
583
664
  return None
584
- evidence = EvidenceState()
585
665
  warnings: list[str] = []
586
666
  citations = _validated_citations_from_content(
587
667
  request.query,
@@ -614,6 +694,13 @@ def _try_exact_fast_path(
614
694
  target=target,
615
695
  allow_assignment=True,
616
696
  )
697
+ citations = _validate_citations(
698
+ citations,
699
+ repo_root=repo_root,
700
+ settings=settings,
701
+ evidence=evidence,
702
+ warnings=warnings,
703
+ )
617
704
  if citations:
618
705
  return _finalize_result(
619
706
  request=request,
@@ -630,25 +717,77 @@ def _try_exact_fast_path(
630
717
  symbol = _extract_pathless_symbol(request.query)
631
718
  if symbol is None:
632
719
  return None
633
- citation = _unique_definition_citation(
720
+ scan = _scan_definitions(
634
721
  repo_root=repo_root,
635
722
  settings=settings,
636
723
  symbol=symbol,
637
724
  )
638
- if citation is None:
725
+ evidence.definition_scan = scan
726
+ if scan.status != "unique":
639
727
  return None
640
728
  return _finalize_result(
641
729
  request=request,
642
730
  repo_root=repo_root,
643
731
  settings=settings,
644
732
  answer="",
645
- citations=[citation],
733
+ citations=list(scan.citations),
646
734
  turns_used=0,
647
735
  truncated=False,
648
736
  warnings=[],
649
737
  )
650
738
 
651
739
 
740
+ def _query_symbol(query: str) -> str | None:
741
+ if not _is_exact_lookup(query) or extract_citations(query):
742
+ return None
743
+ path = _extract_explicit_path(query)
744
+ target = (
745
+ _extract_exact_target(query, path) if path else _extract_pathless_symbol(query)
746
+ )
747
+ if target and re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", target):
748
+ return target
749
+ return None
750
+
751
+
752
+ def _symbol_in_range(
753
+ citation: Citation,
754
+ reads: list[ToolObservation],
755
+ symbol: str | None,
756
+ ) -> bool:
757
+ if symbol is None:
758
+ return True
759
+ pattern = re.compile(rf"\b{re.escape(symbol)}\b")
760
+ for read in reads:
761
+ if not _citation_has_read_coverage(citation, [read]) or read.content is None:
762
+ continue
763
+ start, _ = _parse_line_range(read.line_range or "")
764
+ assert start is not None and citation.start_line is not None
765
+ assert citation.end_line is not None
766
+ lines = read.content.split("\n")
767
+ lines = [line + "\n" for line in lines[:-1]] + lines[-1:]
768
+ text = "".join(
769
+ lines[citation.start_line - start : citation.end_line - start + 1]
770
+ )
771
+ for match in pattern.finditer(text):
772
+ # A byte-capped trailing token has no verified right boundary.
773
+ if match.end() < len(text) or not read.truncated or text.endswith("\n"):
774
+ return True
775
+ return False
776
+
777
+
778
+ def _eligible_reads(evidence: EvidenceState) -> list[ToolObservation]:
779
+ if evidence.symbol is None:
780
+ return evidence.reads
781
+ return [
782
+ read
783
+ for read in evidence.reads
784
+ if any(
785
+ _symbol_in_range(citation, [read], evidence.symbol)
786
+ for citation in _citations_from_reads([read])
787
+ )
788
+ ]
789
+
790
+
652
791
  def _is_exact_lookup(query: str) -> bool:
653
792
  # Admit a small lookup vocabulary; any unexplained intent goes to the model.
654
793
  paths = list(EXPLICIT_PATH_RE.finditer(query))
@@ -769,12 +908,12 @@ def _exact_matches_in_file(
769
908
  return citations if 0 < len(citations) <= MAX_FINAL_CITATIONS else []
770
909
 
771
910
 
772
- def _unique_definition_citation(
911
+ def _scan_definitions(
773
912
  *,
774
913
  repo_root: Path,
775
914
  settings: Settings,
776
915
  symbol: str,
777
- ) -> Citation | None:
916
+ ) -> _DefinitionScan:
778
917
  definition_pattern = rf"^\s*(?:async\s+def|def|class)\s+{re.escape(symbol)}\b"
779
918
  grep_result = repo_grep(
780
919
  repo_root=repo_root,
@@ -797,9 +936,16 @@ def _unique_definition_citation(
797
936
  for result in (grep_result, pyi_result)
798
937
  for hit in result.hits
799
938
  ]
800
- if grep_result.truncated or pyi_result.truncated or len(citations) > 1:
801
- return None
802
- return citations[0] if len(citations) == 1 else None
939
+ status: Literal["unique", "missing", "ambiguous", "incomplete"]
940
+ if grep_result.truncated or pyi_result.truncated:
941
+ status = "incomplete"
942
+ elif len(citations) > 1:
943
+ status = "ambiguous"
944
+ else:
945
+ status = "unique" if citations else "missing"
946
+ return _DefinitionScan(
947
+ tuple(citations), status, tuple(grep_result.warnings + pyi_result.warnings)
948
+ )
803
949
 
804
950
 
805
951
  def _grep_exact_target(
@@ -890,7 +1036,8 @@ def _validate_citations(
890
1036
  if normalized is None:
891
1037
  continue
892
1038
  if _citation_has_read_coverage(normalized, evidence.reads):
893
- validated.append(normalized)
1039
+ if _symbol_in_range(normalized, evidence.reads, evidence.symbol):
1040
+ validated.append(normalized)
894
1041
  continue
895
1042
  verified = _verify_citation_by_read(
896
1043
  normalized,
@@ -899,7 +1046,9 @@ def _validate_citations(
899
1046
  evidence=evidence,
900
1047
  warnings=warnings,
901
1048
  )
902
- if verified is not None:
1049
+ if verified is not None and _symbol_in_range(
1050
+ verified, evidence.reads, evidence.symbol
1051
+ ):
903
1052
  validated.append(verified)
904
1053
  return _merge_adjacent_citations(_dedupe_citations(validated))[:MAX_FINAL_CITATIONS]
905
1054
 
@@ -1016,9 +1165,23 @@ def _dedupe_citations(citations: list[Citation]) -> list[Citation]:
1016
1165
 
1017
1166
 
1018
1167
  def _best_current_citations(evidence: EvidenceState) -> list[Citation]:
1168
+ reads = _eligible_reads(evidence)
1169
+ if evidence.prefer_source:
1170
+ source_reads = [
1171
+ read for read in reads if not _documentation_path(read.path or "")
1172
+ ]
1173
+ source_citations = [
1174
+ citation
1175
+ for citation in evidence.citations
1176
+ if not _documentation_path(citation.path)
1177
+ ]
1178
+ if source_citations or source_reads:
1179
+ return source_citations[:MAX_FINAL_CITATIONS] or _citations_from_reads(
1180
+ source_reads
1181
+ )
1019
1182
  if evidence.citations:
1020
1183
  return evidence.citations[:MAX_FINAL_CITATIONS]
1021
- return _citations_from_reads(evidence.reads)
1184
+ return _citations_from_reads(reads)
1022
1185
 
1023
1186
 
1024
1187
  def _citations_from_reads(observations: list[ToolObservation]) -> list[Citation]:
@@ -1070,7 +1233,7 @@ def _best_narrow_read_citation(
1070
1233
  evidence: EvidenceState,
1071
1234
  settings: Settings,
1072
1235
  ) -> Citation | None:
1073
- for citation in _citations_from_reads(evidence.reads):
1236
+ for citation in _citations_from_reads(_eligible_reads(evidence)):
1074
1237
  for observation in evidence.reads:
1075
1238
  start, end = _parse_line_range(observation.line_range or "")
1076
1239
  if (
@@ -1120,17 +1283,44 @@ def _tool_call_fingerprint(tool_call: ToolCall) -> str:
1120
1283
  )
1121
1284
 
1122
1285
 
1123
- def _first_repeated_tool_call(
1124
- evidence: EvidenceState,
1125
- tool_calls: list[ToolCall],
1126
- ) -> ToolCall | None:
1127
- batch_fingerprints: set[str] = set()
1128
- for tool_call in tool_calls:
1129
- fingerprint = _tool_call_fingerprint(tool_call)
1130
- if fingerprint in evidence.seen_tool_calls or fingerprint in batch_fingerprints:
1131
- return tool_call
1132
- batch_fingerprints.add(fingerprint)
1133
- return None
1286
+ def _implementation_query(query: str) -> bool:
1287
+ if _is_exact_lookup(query) or re.search(
1288
+ r"\b(?:docs?|documentation|markdown|readme)\b", query, re.IGNORECASE
1289
+ ):
1290
+ return False
1291
+ return bool(
1292
+ re.search(
1293
+ r"\b(?:implement\w*|handl\w*|code|functions?|methods?|classes|class)\b",
1294
+ query,
1295
+ re.IGNORECASE,
1296
+ )
1297
+ )
1298
+
1299
+
1300
+ def _documentation_path(path: str) -> bool:
1301
+ name = Path(path).name.lower()
1302
+ return Path(name).suffix in {".md", ".mdx", ".rst", ".txt", ".adoc"} or name in {
1303
+ "readme",
1304
+ "changelog",
1305
+ "contributing",
1306
+ "license",
1307
+ "authors",
1308
+ }
1309
+
1310
+
1311
+ def _documentation_only(citations: list[Citation]) -> bool:
1312
+ return bool(citations) and all(_documentation_path(c.path) for c in citations)
1313
+
1314
+
1315
+ def _source_feedback() -> dict[str, str]:
1316
+ return {
1317
+ "role": "user",
1318
+ "content": (
1319
+ "These citations only establish documentation evidence. Continue searching "
1320
+ "for implementation source using fresh, focused read-only tool calls. "
1321
+ "Do not repeat previous calls or bypass denied paths."
1322
+ ),
1323
+ }
1134
1324
 
1135
1325
 
1136
1326
  def _message_has_tool_calls(message: dict[str, Any]) -> bool:
@@ -1245,6 +1435,8 @@ def _initial_messages(
1245
1435
  "tool calls in the same turn. "
1246
1436
  "For exact symbol, function, or class names, use repo_grep "
1247
1437
  "before reading broad file ranges. "
1438
+ "Exact-symbol citations must contain the whole case-sensitive "
1439
+ "query token inside the cited lines. "
1248
1440
  "For implementation questions, prefer implementation source over "
1249
1441
  "documentation or Markdown; use documentation when requested or "
1250
1442
  "needed to support the answer. Cite the smallest line ranges "
@@ -6,6 +6,7 @@ from importlib import import_module
6
6
  from pathlib import Path
7
7
  from typing import Any
8
8
 
9
+ from flash_context import __version__
9
10
  from flash_context.agent import explore
10
11
  from flash_context.config import Settings, SettingsOverrides, load_settings
11
12
  from flash_context.types import ExploreRequest, ExplorerError, ExploreResult
@@ -52,6 +53,8 @@ def create_server() -> Any:
52
53
  ) from exc
53
54
  fast_mcp = fastmcp_module.FastMCP
54
55
  server = fast_mcp("flash-context")
56
+ # FastMCP exposes no version argument; the low-level default is the SDK version.
57
+ server._mcp_server.version = __version__
55
58
 
56
59
  @server.tool() # type: ignore[untyped-decorator]
57
60
  def explore_repository(
File without changes
File without changes