flash-context 0.2.0__tar.gz → 0.3.1__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.1/CHANGELOG.md +87 -0
  2. {flash_context-0.2.0 → flash_context-0.3.1}/PKG-INFO +21 -4
  3. {flash_context-0.2.0 → flash_context-0.3.1}/README.md +20 -3
  4. {flash_context-0.2.0 → flash_context-0.3.1}/pyproject.toml +3 -2
  5. {flash_context-0.2.0 → flash_context-0.3.1}/src/flash_context/agent.py +167 -76
  6. {flash_context-0.2.0 → flash_context-0.3.1}/src/flash_context/cli.py +15 -0
  7. {flash_context-0.2.0 → flash_context-0.3.1}/src/flash_context/logging.py +13 -10
  8. {flash_context-0.2.0 → flash_context-0.3.1}/src/flash_context/mcp_server.py +10 -0
  9. {flash_context-0.2.0 → flash_context-0.3.1}/src/flash_context/tools/safety.py +25 -1
  10. {flash_context-0.2.0 → flash_context-0.3.1}/src/flash_context/types.py +0 -2
  11. {flash_context-0.2.0 → flash_context-0.3.1}/.env.example +0 -0
  12. {flash_context-0.2.0 → flash_context-0.3.1}/.gitignore +0 -0
  13. {flash_context-0.2.0 → flash_context-0.3.1}/LICENSE +0 -0
  14. {flash_context-0.2.0 → flash_context-0.3.1}/config.yaml.example +0 -0
  15. {flash_context-0.2.0 → flash_context-0.3.1}/src/flash_context/__init__.py +0 -0
  16. {flash_context-0.2.0 → flash_context-0.3.1}/src/flash_context/config.py +0 -0
  17. {flash_context-0.2.0 → flash_context-0.3.1}/src/flash_context/llm.py +0 -0
  18. {flash_context-0.2.0 → flash_context-0.3.1}/src/flash_context/tools/__init__.py +0 -0
  19. {flash_context-0.2.0 → flash_context-0.3.1}/src/flash_context/tools/glob.py +0 -0
  20. {flash_context-0.2.0 → flash_context-0.3.1}/src/flash_context/tools/grep.py +0 -0
  21. {flash_context-0.2.0 → flash_context-0.3.1}/src/flash_context/tools/read.py +0 -0
@@ -0,0 +1,87 @@
1
+ # Changelog
2
+
3
+ User-visible changes and migration instructions for each release.
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [0.3.1] - 2026-11-01
8
+
9
+ ### Fixed
10
+
11
+ - Report the flash-context package version during MCP initialization instead of
12
+ the MCP SDK version.
13
+ - Reuse repeated tool observations without discarding fresh searches in the same
14
+ batch; execute same-turn duplicates only once and stop all-repeat stalls.
15
+ - Continue implementation searches after documentation-only citations within the
16
+ existing turn budget. Documentation-only fallbacks retain citations and snippets
17
+ with explicit incomplete-result metadata and warnings.
18
+
19
+ ### Changed
20
+
21
+ - Describe deterministic lookup and delegated model search together in the README.
22
+
23
+ ### Migration
24
+
25
+ - Implementation questions may use additional turns before returning documentation
26
+ evidence. These fallbacks set `truncated: true`; partial cited CLI results still
27
+ exit 0. Budgets, configuration keys, and result schemas are unchanged.
28
+
29
+ ## [0.3.0] - 2026-11-01
30
+
31
+ ### Fixed
32
+
33
+ - Resolve bare file-path queries locally with bounded previews and find unique
34
+ Python definitions even when many references precede them.
35
+ - Keep literal and scoped searches from scanning unrelated directory trees while
36
+ preserving safety checks and existing resource limits.
37
+ - Surface scan failures from the initial file sample and CLI result warnings.
38
+ - Describe the MCP exploration tool during discovery and use tool-specific
39
+ trajectory counters instead of misleading empty read-content counts.
40
+
41
+ ### Migration
42
+
43
+ - Citation objects no longer include the unused `reason` field. Consumers should
44
+ read `path`, `start_line`, and `end_line` without expecting `reason: null`.
45
+ - The CLI now exits 6 for empty results after scan-budget exhaustion or a tool
46
+ processing timeout, retaining text/JSON on stdout and warnings on stderr.
47
+ Partial cited results and complete empty searches still exit 0.
48
+ - Trajectory tool entries now include only their relevant counter:
49
+ `content_length` (read), `hit_count` (grep), or `match_count` (glob).
50
+
51
+ ### Changed
52
+
53
+ - Prompt the explorer to prefer implementation source for coding questions and
54
+ the smallest supporting citation ranges. Validation checks safe line coverage;
55
+ precision and relevance remain model-dependent.
56
+ - Publish GitHub releases automatically after PyPI installation verification,
57
+ using this changelog and the same distribution artifacts uploaded to PyPI.
58
+ - Refresh package-index metadata during release installation retries.
59
+
60
+ ## [0.2.0] - 2026-11-01
61
+
62
+ ### Changed
63
+
64
+ - Enforce deny/ignore rules on requested and resolved paths, including environment
65
+ files, private-key names, and runtime histories. Prune denied directories.
66
+ - Bound scan bytes, directory entries, processing time, regex execution, tool
67
+ batches, and model observations; report incomplete coverage explicitly.
68
+ - Improve exact-lookup routing, read ranges, truncation metadata, and structured
69
+ endpoint/filesystem failures across CLI and MCP.
70
+ - Keep optional trajectory logs metadata-only and outside the inspected root.
71
+
72
+ ### Migration
73
+
74
+ - Move any trajectory directory inside the inspected repository outside it, or
75
+ disable logging with `FLASH_CONTEXT_TRAJ_DIR=`. Empty/null settings disable
76
+ inherited logging. Existing logs remain untouched.
77
+
78
+ ## [0.1.0] - 2026-11-01
79
+
80
+ ### Added
81
+
82
+ - Initial Python CLI and optional MCP adapter sharing a read-only explorer core.
83
+ - OpenAI-compatible tool-call loop with bounded Read/Glob/Grep repository tools,
84
+ root-scoped path validation, validated citations, and raw source snippets.
85
+ - Exact path/symbol lookup shortcuts, concurrent local tools, and configurable
86
+ endpoint settings and limits.
87
+ - Wheel/source packaging and tag-driven TestPyPI/PyPI publication and smoke checks.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: flash-context
3
- Version: 0.2.0
3
+ Version: 0.3.1
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,8 +28,9 @@ 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 that combines deterministic
32
+ lookup with delegated LLM search to deliver compact, validated source evidence.
33
+ Available as a Python CLI and Model Context Protocol (MCP) server.
33
34
 
34
35
  Delegate recursive, repetitive file search and reading to a utility model
35
36
  behind an OpenAI-compatible endpoint. The calling agent receives validated
@@ -39,10 +40,17 @@ Codebases and repositories are the current focus; the same tools explore
39
40
  documentation, configuration, and other local text files. CLI and MCP share one
40
41
  exploration core with read-only tools, path safety, and resource limits.
41
42
  Scans and regex execution are bounded; partial results report truncation.
43
+ Citation validation checks safe paths and existing line coverage, not semantic
44
+ relevance or minimal ranges. Citation precision depends on the explorer model.
45
+ Implementation queries continue past documentation-only citations within the turn budget; documentation-only
46
+ fallbacks carry incomplete-result metadata and warnings. Build outputs such as
47
+ `dist/` remain excluded; inspect a source checkout for implementation evidence.
42
48
  Optional metadata-only trajectory logs must stay outside the inspected repository.
43
49
 
44
50
  Inspired by [FastContext](https://arxiv.org/abs/2606.14066), a now-withdrawn paper
45
- on delegating repository exploration to a dedicated model.
51
+ on separating repository exploration from task solving, using specialized models
52
+ to return focused file-line evidence while reducing the coding agent's context
53
+ and token overhead.
46
54
 
47
55
  ## Quickstart
48
56
 
@@ -73,6 +81,14 @@ lookups can resolve locally without an endpoint. See the
73
81
  [CLI contract](specs/001-flash-context-explorer/contracts/cli.md) for options,
74
82
  output schemas, and exit codes.
75
83
 
84
+ For example, `--query 'httpx/_client.py'` returns a bounded file preview, while
85
+ `--query 'BaseTransport'` returns its definition if unique within a complete
86
+ Python source/stub scan. Local successes report `turns_used: 0` in JSON.
87
+
88
+ Warnings go to stderr in both output formats. An empty result after scan-budget
89
+ exhaustion exits **6** while preserving stdout; a complete empty search exits
90
+ **0**. For large repositories, see [scan-budget tuning](docs/configuration.md#large-repositories).
91
+
76
92
  ## MCP
77
93
 
78
94
  ```bash
@@ -87,6 +103,7 @@ See [MCP integration](docs/mcp.md) for editor configuration and source launches.
87
103
  - [Configuration](docs/configuration.md): endpoint settings, precedence, and limits.
88
104
  - [Architecture and scope](docs/architecture.md): design, status, specs, and references.
89
105
  - [Contributing](CONTRIBUTING.md): development setup, tests, and pull requests.
106
+ - [Changelog](CHANGELOG.md): release history and migration instructions.
90
107
  - [Releasing](docs/releasing.md): package checks and tag-driven TestPyPI/PyPI publishing.
91
108
 
92
109
  ## License
@@ -5,8 +5,9 @@
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.
8
+ A lightweight, model-agnostic repository explorer that combines deterministic
9
+ lookup with delegated LLM search to deliver compact, validated source evidence.
10
+ Available as a Python CLI and Model Context Protocol (MCP) server.
10
11
 
11
12
  Delegate recursive, repetitive file search and reading to a utility model
12
13
  behind an OpenAI-compatible endpoint. The calling agent receives validated
@@ -16,10 +17,17 @@ Codebases and repositories are the current focus; the same tools explore
16
17
  documentation, configuration, and other local text files. CLI and MCP share one
17
18
  exploration core with read-only tools, path safety, and resource limits.
18
19
  Scans and regex execution are bounded; partial results report truncation.
20
+ Citation validation checks safe paths and existing line coverage, not semantic
21
+ relevance or minimal ranges. Citation precision depends on the explorer model.
22
+ Implementation queries continue past documentation-only citations within the turn budget; documentation-only
23
+ fallbacks carry incomplete-result metadata and warnings. Build outputs such as
24
+ `dist/` remain excluded; inspect a source checkout for implementation evidence.
19
25
  Optional metadata-only trajectory logs must stay outside the inspected repository.
20
26
 
21
27
  Inspired by [FastContext](https://arxiv.org/abs/2606.14066), a now-withdrawn paper
22
- on delegating repository exploration to a dedicated model.
28
+ on separating repository exploration from task solving, using specialized models
29
+ to return focused file-line evidence while reducing the coding agent's context
30
+ and token overhead.
23
31
 
24
32
  ## Quickstart
25
33
 
@@ -50,6 +58,14 @@ lookups can resolve locally without an endpoint. See the
50
58
  [CLI contract](specs/001-flash-context-explorer/contracts/cli.md) for options,
51
59
  output schemas, and exit codes.
52
60
 
61
+ For example, `--query 'httpx/_client.py'` returns a bounded file preview, while
62
+ `--query 'BaseTransport'` returns its definition if unique within a complete
63
+ Python source/stub scan. Local successes report `turns_used: 0` in JSON.
64
+
65
+ Warnings go to stderr in both output formats. An empty result after scan-budget
66
+ exhaustion exits **6** while preserving stdout; a complete empty search exits
67
+ **0**. For large repositories, see [scan-budget tuning](docs/configuration.md#large-repositories).
68
+
53
69
  ## MCP
54
70
 
55
71
  ```bash
@@ -64,6 +80,7 @@ See [MCP integration](docs/mcp.md) for editor configuration and source launches.
64
80
  - [Configuration](docs/configuration.md): endpoint settings, precedence, and limits.
65
81
  - [Architecture and scope](docs/architecture.md): design, status, specs, and references.
66
82
  - [Contributing](CONTRIBUTING.md): development setup, tests, and pull requests.
83
+ - [Changelog](CHANGELOG.md): release history and migration instructions.
67
84
  - [Releasing](docs/releasing.md): package checks and tag-driven TestPyPI/PyPI publishing.
68
85
 
69
86
  ## License
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flash-context"
3
- version = "0.2.0"
3
+ version = "0.3.1"
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"
@@ -52,6 +52,7 @@ include = [
52
52
  "/src/flash_context",
53
53
  "/pyproject.toml",
54
54
  "/README.md",
55
+ "/CHANGELOG.md",
55
56
  "/LICENSE",
56
57
  "/config.yaml.example",
57
58
  "/.env.example",
@@ -74,5 +75,5 @@ select = ["E", "F", "I", "UP", "B", "SIM"]
74
75
  [tool.mypy]
75
76
  python_version = "3.13"
76
77
  strict = true
77
- files = ["src/flash_context", "tests/unit", "tests/contract"]
78
+ files = ["src/flash_context", "tests/unit", "tests/contract", "scripts"]
78
79
  exclude = ["tests/fixtures"]
@@ -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
 
@@ -66,7 +66,8 @@ TOKEN_RE = re.compile(r"\b(?P<token>[A-Za-z_][A-Za-z0-9_]*)\b")
66
66
  class EvidenceState:
67
67
  reads: list[ToolObservation] = field(default_factory=list)
68
68
  citations: list[Citation] = field(default_factory=list)
69
- seen_tool_calls: set[str] = field(default_factory=set)
69
+ observations: dict[str, ToolObservation] = field(default_factory=dict)
70
+ prefer_source: bool = False
70
71
 
71
72
 
72
73
  def explore(
@@ -99,10 +100,11 @@ def explore(
99
100
  )
100
101
  messages = _initial_messages(request, file_sample, settings)
101
102
  tools = tool_schemas()
102
- truncated = False
103
- warnings: list[str] = []
103
+ # A capped file sample is expected; actual scan failures must reach callers.
104
+ warnings = list(file_sample.warnings)
105
+ truncated = bool(warnings)
104
106
  last_content = ""
105
- evidence = EvidenceState()
107
+ evidence = EvidenceState(prefer_source=_implementation_query(request.query))
106
108
 
107
109
  try:
108
110
  for turn in range(1, effective_max_turns + 1):
@@ -144,6 +146,7 @@ def explore(
144
146
  recorder.finish(result=result)
145
147
  return result
146
148
  recorder.record_model_turn(turn, message)
149
+ needs_source = False
147
150
  content_text = str(message.get("content") or "").strip()
148
151
  if content_text:
149
152
  last_content = content_text
@@ -156,6 +159,12 @@ def explore(
156
159
  )
157
160
  if citations:
158
161
  evidence.citations = citations
162
+ needs_source = evidence.prefer_source and _documentation_only(
163
+ citations
164
+ )
165
+ if needs_source:
166
+ last_content = ""
167
+ if citations and not needs_source:
159
168
  if _message_has_tool_calls(message):
160
169
  _append_warning_once(
161
170
  warnings,
@@ -177,6 +186,11 @@ def explore(
177
186
  tool_calls = _extract_tool_calls(message)
178
187
  if not tool_calls:
179
188
  citations = _best_current_citations(evidence)
189
+ if evidence.prefer_source and _documentation_only(citations):
190
+ last_content = ""
191
+ messages.append({"role": "assistant", "content": content_text})
192
+ messages.append(_source_feedback())
193
+ continue
180
194
  if request.citation and content_text != NO_CITATIONS_FOUND:
181
195
  _append_warning_once(
182
196
  warnings,
@@ -186,7 +200,7 @@ def explore(
186
200
  request=request,
187
201
  repo_root=repo_root,
188
202
  settings=settings,
189
- answer=content_text,
203
+ answer="" if needs_source else content_text,
190
204
  citations=citations,
191
205
  turns_used=turn,
192
206
  truncated=truncated,
@@ -218,8 +232,12 @@ def explore(
218
232
  recorder.finish(result=result)
219
233
  return result
220
234
 
221
- repeated_call = _first_repeated_tool_call(evidence, tool_calls)
222
- if repeated_call is not None:
235
+ fresh_calls: dict[str, ToolCall] = {}
236
+ for tool_call in tool_calls:
237
+ fingerprint = _tool_call_fingerprint(tool_call)
238
+ if fingerprint not in evidence.observations:
239
+ fresh_calls.setdefault(fingerprint, tool_call)
240
+ if not fresh_calls:
223
241
  _append_warning_once(
224
242
  warnings,
225
243
  "repeated tool call stopped exploration",
@@ -237,17 +255,18 @@ def explore(
237
255
  )
238
256
  recorder.finish(result=result)
239
257
  return result
240
- for tool_call in tool_calls:
241
- evidence.seen_tool_calls.add(_tool_call_fingerprint(tool_call))
242
-
243
258
  messages.append(_assistant_tool_message(message))
244
259
  observations = _execute_tool_calls_parallel(
245
- tool_calls,
260
+ list(fresh_calls.values()),
246
261
  repo_root=repo_root,
247
262
  settings=settings,
248
263
  )
249
- for tool_call, observation in zip(tool_calls, observations, strict=True):
250
- observation.tool_call_id = tool_call.id
264
+ evidence.observations.update(zip(fresh_calls, observations, strict=True))
265
+ for tool_call in tool_calls:
266
+ observation = replace(
267
+ evidence.observations[_tool_call_fingerprint(tool_call)],
268
+ tool_call_id=tool_call.id,
269
+ )
251
270
  payload = _model_observation_payload(
252
271
  observation,
253
272
  max_chars=settings.max_observation_chars,
@@ -261,7 +280,9 @@ def explore(
261
280
  and observation.path
262
281
  and observation.line_range
263
282
  ):
264
- evidence.reads.append(observation)
283
+ read = replace(observation, tool_call_id=None)
284
+ if read not in evidence.reads:
285
+ evidence.reads.append(read)
265
286
  recorder.record_observation(turn, tool_call.name, observation)
266
287
  messages.append(
267
288
  {
@@ -274,6 +295,8 @@ def explore(
274
295
  ),
275
296
  }
276
297
  )
298
+ if needs_source:
299
+ messages.append(_source_feedback())
277
300
  if last_content:
278
301
  citations = _validated_citations_from_content(
279
302
  last_content,
@@ -417,6 +440,13 @@ def _finalize_result(
417
440
  warnings: list[str],
418
441
  ) -> ExploreResult:
419
442
  citations = _merge_adjacent_citations(citations)[:MAX_FINAL_CITATIONS]
443
+ if _implementation_query(request.query) and _documentation_only(citations):
444
+ truncated = True
445
+ answer = _citation_text(citations)
446
+ _append_warning_once(
447
+ warnings,
448
+ "implementation source not verified; returning documentation evidence",
449
+ )
420
450
  if request.citation or (not answer and citations):
421
451
  answer = _citation_text(citations)
422
452
  raw_locations = _extract_raw_locations(
@@ -436,6 +466,7 @@ def _finalize_result(
436
466
  truncated
437
467
  or any(x.truncated for x in raw_locations)
438
468
  or "citation verification limit reached" in warnings
469
+ or "read scan budget exhausted" in warnings
439
470
  ),
440
471
  warnings=warnings,
441
472
  )
@@ -448,33 +479,30 @@ def _citation_text(citations: list[Citation]) -> str:
448
479
 
449
480
 
450
481
  def _merge_adjacent_citations(citations: list[Citation]) -> list[Citation]:
451
- grouped: dict[str, list[tuple[int, Citation]]] = {}
482
+ grouped: dict[str, list[Citation]] = {}
452
483
  path_order: list[str] = []
453
484
  non_mergeable: list[Citation] = []
454
- for index, citation in enumerate(citations):
485
+ for citation in citations:
455
486
  if citation.start_line is None or citation.end_line is None:
456
487
  non_mergeable.append(citation)
457
488
  continue
458
489
  if citation.path not in grouped:
459
490
  grouped[citation.path] = []
460
491
  path_order.append(citation.path)
461
- grouped[citation.path].append((index, citation))
492
+ grouped[citation.path].append(citation)
462
493
 
463
494
  merged: list[Citation] = []
464
495
  for path in path_order:
465
496
  ranges = sorted(
466
497
  grouped[path],
467
498
  key=lambda item: (
468
- item[1].start_line or 0,
469
- item[1].end_line or 0,
499
+ item.start_line or 0,
500
+ item.end_line or 0,
470
501
  ),
471
502
  )
472
503
  current_start: int | None = None
473
504
  current_end: int | None = None
474
- current_reason: str | None = None
475
- current_reason_index: int | None = None
476
-
477
- for index, citation in ranges:
505
+ for citation in ranges:
478
506
  start = citation.start_line
479
507
  end = citation.end_line
480
508
  if start is None or end is None:
@@ -482,27 +510,18 @@ def _merge_adjacent_citations(citations: list[Citation]) -> list[Citation]:
482
510
  if current_start is None or current_end is None:
483
511
  current_start = start
484
512
  current_end = end
485
- current_reason = citation.reason
486
- current_reason_index = index if citation.reason else None
487
513
  continue
488
514
 
489
515
  if start <= current_end + 1:
490
516
  current_end = max(current_end, end)
491
- if citation.reason and (
492
- current_reason_index is None or index < current_reason_index
493
- ):
494
- current_reason = citation.reason
495
- current_reason_index = index
496
517
  continue
497
518
 
498
- merged.append(Citation(path, current_start, current_end, current_reason))
519
+ merged.append(Citation(path, current_start, current_end))
499
520
  current_start = start
500
521
  current_end = end
501
- current_reason = citation.reason
502
- current_reason_index = index if citation.reason else None
503
522
 
504
523
  if current_start is not None and current_end is not None:
505
- merged.append(Citation(path, current_start, current_end, current_reason))
524
+ merged.append(Citation(path, current_start, current_end))
506
525
 
507
526
  merged.extend(non_mergeable)
508
527
  return merged
@@ -562,6 +581,33 @@ def _try_exact_fast_path(
562
581
  repo_root: Path,
563
582
  settings: Settings,
564
583
  ) -> ExploreResult | None:
584
+ bare_path = request.query.strip()
585
+ if len(bare_path) >= 2 and bare_path[0] in "`'\"" and bare_path[-1] == bare_path[0]:
586
+ bare_path = bare_path[1:-1]
587
+ if re.fullmatch(r"(?:[A-Za-z0-9_.-]+/)*[A-Za-z0-9_.-]+", bare_path):
588
+ try:
589
+ observation = read_file(
590
+ repo_root=repo_root,
591
+ path=bare_path,
592
+ end_line=settings.max_read_lines,
593
+ max_bytes=settings.max_read_bytes,
594
+ preview=True,
595
+ ignore=settings.ignore,
596
+ limits=settings.limits,
597
+ )
598
+ except ExplorerError:
599
+ pass # Missing, empty, or unsafe paths retain normal model routing.
600
+ else:
601
+ return _finalize_result(
602
+ request=request,
603
+ repo_root=repo_root,
604
+ settings=settings,
605
+ answer="",
606
+ citations=_citations_from_reads([observation]),
607
+ turns_used=0,
608
+ truncated=observation.truncated,
609
+ warnings=list(observation.warnings),
610
+ )
565
611
  if not _is_exact_lookup(request.query):
566
612
  return None
567
613
  evidence = EvidenceState()
@@ -748,7 +794,6 @@ def _exact_matches_in_file(
748
794
  grep_result,
749
795
  target=target,
750
796
  allow_assignment=allow_assignment,
751
- definitions_only=False,
752
797
  )
753
798
  return citations if 0 < len(citations) <= MAX_FINAL_CITATIONS else []
754
799
 
@@ -759,32 +804,28 @@ def _unique_definition_citation(
759
804
  settings: Settings,
760
805
  symbol: str,
761
806
  ) -> Citation | None:
762
- grep_result = _grep_exact_target(
807
+ definition_pattern = rf"^\s*(?:async\s+def|def|class)\s+{re.escape(symbol)}\b"
808
+ grep_result = repo_grep(
763
809
  repo_root=repo_root,
764
- settings=settings,
765
- target=symbol,
810
+ pattern=definition_pattern,
766
811
  glob="**/*.py",
812
+ max_results=2,
813
+ ignore=settings.ignore,
814
+ limits=settings.limits,
767
815
  )
768
- pyi_result = _grep_exact_target(
816
+ pyi_result = repo_grep(
769
817
  repo_root=repo_root,
770
- settings=settings,
771
- target=symbol,
818
+ pattern=definition_pattern,
772
819
  glob="**/*.pyi",
820
+ max_results=2,
821
+ ignore=settings.ignore,
822
+ limits=settings.limits,
773
823
  )
774
- citations = _citations_for_target_hits(
775
- grep_result,
776
- target=symbol,
777
- allow_assignment=False,
778
- definitions_only=True,
779
- )
780
- citations.extend(
781
- _citations_for_target_hits(
782
- pyi_result,
783
- target=symbol,
784
- allow_assignment=False,
785
- definitions_only=True,
786
- )
787
- )
824
+ citations = [
825
+ Citation(hit.path, hit.line, hit.line)
826
+ for result in (grep_result, pyi_result)
827
+ for hit in result.hits
828
+ ]
788
829
  if grep_result.truncated or pyi_result.truncated or len(citations) > 1:
789
830
  return None
790
831
  return citations[0] if len(citations) == 1 else None
@@ -812,7 +853,6 @@ def _citations_for_target_hits(
812
853
  *,
813
854
  target: str,
814
855
  allow_assignment: bool,
815
- definitions_only: bool,
816
856
  ) -> list[Citation]:
817
857
  citations: list[Citation] = []
818
858
  for hit in observation.hits:
@@ -820,7 +860,6 @@ def _citations_for_target_hits(
820
860
  hit.text,
821
861
  target=target,
822
862
  allow_assignment=allow_assignment,
823
- definitions_only=definitions_only,
824
863
  ):
825
864
  citations.append(Citation(hit.path, hit.line, hit.line))
826
865
  return citations
@@ -831,14 +870,11 @@ def _line_matches_target(
831
870
  *,
832
871
  target: str,
833
872
  allow_assignment: bool,
834
- definitions_only: bool,
835
873
  ) -> bool:
836
874
  escaped = re.escape(target)
837
875
  definition_pattern = rf"^\s*(?:async\s+def|def|class)\s+{escaped}\b"
838
876
  if re.search(definition_pattern, line):
839
877
  return True
840
- if definitions_only:
841
- return False
842
878
  if allow_assignment and re.search(rf"^\s*{escaped}\s*(?::|=)", line):
843
879
  return True
844
880
  return bool(re.search(rf"\b{escaped}\b", line))
@@ -862,6 +898,7 @@ def _validated_citations_from_content(
862
898
  repo_root=repo_root,
863
899
  settings=settings,
864
900
  evidence=evidence,
901
+ warnings=warnings,
865
902
  )
866
903
  if not validated:
867
904
  _append_warning_once(warnings, "invalid citation rejected")
@@ -874,6 +911,7 @@ def _validate_citations(
874
911
  repo_root: Path,
875
912
  settings: Settings,
876
913
  evidence: EvidenceState,
914
+ warnings: list[str],
877
915
  ) -> list[Citation]:
878
916
  validated: list[Citation] = []
879
917
  for citation in citations[:16]:
@@ -888,6 +926,7 @@ def _validate_citations(
888
926
  repo_root=repo_root,
889
927
  settings=settings,
890
928
  evidence=evidence,
929
+ warnings=warnings,
891
930
  )
892
931
  if verified is not None:
893
932
  validated.append(verified)
@@ -909,7 +948,6 @@ def _normalize_citation(citation: Citation) -> Citation | None:
909
948
  path=citation.path,
910
949
  start_line=citation.start_line,
911
950
  end_line=citation.end_line,
912
- reason=citation.reason,
913
951
  )
914
952
 
915
953
 
@@ -961,6 +999,7 @@ def _verify_citation_by_read(
961
999
  repo_root: Path,
962
1000
  settings: Settings,
963
1001
  evidence: EvidenceState,
1002
+ warnings: list[str],
964
1003
  ) -> Citation | None:
965
1004
  if citation.start_line is None or citation.end_line is None:
966
1005
  return None
@@ -974,13 +1013,16 @@ def _verify_citation_by_read(
974
1013
  ignore=settings.ignore,
975
1014
  limits=settings.limits,
976
1015
  )
977
- except ExplorerError:
1016
+ except ExplorerError as exc:
1017
+ if exc.code == "TOOL_LIMIT_EXCEEDED":
1018
+ _append_warning_once(warnings, "read scan budget exhausted")
978
1019
  return None
1020
+ for warning in observation.warnings:
1021
+ _append_warning_once(warnings, warning)
979
1022
  verified = Citation(
980
1023
  path=observation.path or citation.path,
981
1024
  start_line=citation.start_line,
982
1025
  end_line=citation.end_line,
983
- reason=citation.reason,
984
1026
  )
985
1027
  if verified.path != citation.path:
986
1028
  return None
@@ -1003,6 +1045,19 @@ def _dedupe_citations(citations: list[Citation]) -> list[Citation]:
1003
1045
 
1004
1046
 
1005
1047
  def _best_current_citations(evidence: EvidenceState) -> list[Citation]:
1048
+ if evidence.prefer_source:
1049
+ source_reads = [
1050
+ read for read in evidence.reads if not _documentation_path(read.path or "")
1051
+ ]
1052
+ source_citations = [
1053
+ citation
1054
+ for citation in evidence.citations
1055
+ if not _documentation_path(citation.path)
1056
+ ]
1057
+ if source_citations or source_reads:
1058
+ return source_citations[:MAX_FINAL_CITATIONS] or _citations_from_reads(
1059
+ source_reads
1060
+ )
1006
1061
  if evidence.citations:
1007
1062
  return evidence.citations[:MAX_FINAL_CITATIONS]
1008
1063
  return _citations_from_reads(evidence.reads)
@@ -1107,17 +1162,44 @@ def _tool_call_fingerprint(tool_call: ToolCall) -> str:
1107
1162
  )
1108
1163
 
1109
1164
 
1110
- def _first_repeated_tool_call(
1111
- evidence: EvidenceState,
1112
- tool_calls: list[ToolCall],
1113
- ) -> ToolCall | None:
1114
- batch_fingerprints: set[str] = set()
1115
- for tool_call in tool_calls:
1116
- fingerprint = _tool_call_fingerprint(tool_call)
1117
- if fingerprint in evidence.seen_tool_calls or fingerprint in batch_fingerprints:
1118
- return tool_call
1119
- batch_fingerprints.add(fingerprint)
1120
- return None
1165
+ def _implementation_query(query: str) -> bool:
1166
+ if _is_exact_lookup(query) or re.search(
1167
+ r"\b(?:docs?|documentation|markdown|readme)\b", query, re.IGNORECASE
1168
+ ):
1169
+ return False
1170
+ return bool(
1171
+ re.search(
1172
+ r"\b(?:implement\w*|handl\w*|code|functions?|methods?|classes|class)\b",
1173
+ query,
1174
+ re.IGNORECASE,
1175
+ )
1176
+ )
1177
+
1178
+
1179
+ def _documentation_path(path: str) -> bool:
1180
+ name = Path(path).name.lower()
1181
+ return Path(name).suffix in {".md", ".mdx", ".rst", ".txt", ".adoc"} or name in {
1182
+ "readme",
1183
+ "changelog",
1184
+ "contributing",
1185
+ "license",
1186
+ "authors",
1187
+ }
1188
+
1189
+
1190
+ def _documentation_only(citations: list[Citation]) -> bool:
1191
+ return bool(citations) and all(_documentation_path(c.path) for c in citations)
1192
+
1193
+
1194
+ def _source_feedback() -> dict[str, str]:
1195
+ return {
1196
+ "role": "user",
1197
+ "content": (
1198
+ "These citations only establish documentation evidence. Continue searching "
1199
+ "for implementation source using fresh, focused read-only tool calls. "
1200
+ "Do not repeat previous calls or bypass denied paths."
1201
+ ),
1202
+ }
1121
1203
 
1122
1204
 
1123
1205
  def _message_has_tool_calls(message: dict[str, Any]) -> bool:
@@ -1232,6 +1314,11 @@ def _initial_messages(
1232
1314
  "tool calls in the same turn. "
1233
1315
  "For exact symbol, function, or class names, use repo_grep "
1234
1316
  "before reading broad file ranges. "
1317
+ "For implementation questions, prefer implementation source over "
1318
+ "documentation or Markdown; use documentation when requested or "
1319
+ "needed to support the answer. Cite the smallest line ranges "
1320
+ "that support the answer, rather than whole files or classes "
1321
+ "when a smaller function or region suffices. "
1235
1322
  f"Use read_file line ranges of at most {settings.max_read_lines} "
1236
1323
  "lines unless no narrower range is known. "
1237
1324
  f"{output_contract}"
@@ -1464,7 +1551,11 @@ def _execute_tool_call(
1464
1551
  limits=settings.limits,
1465
1552
  )
1466
1553
  except ExplorerError as exc:
1467
- return ToolObservation.from_error(exc, tool_call_id=tool_call.id)
1554
+ observation = ToolObservation.from_error(exc, tool_call_id=tool_call.id)
1555
+ if exc.code == "TOOL_LIMIT_EXCEEDED":
1556
+ observation.truncated = True
1557
+ observation.warnings.append("read scan budget exhausted")
1558
+ return observation
1468
1559
  raise AssertionError(f"unhandled tool: {tool_call.name}")
1469
1560
 
1470
1561
 
@@ -11,6 +11,15 @@ from flash_context.config import SettingsOverrides, load_settings
11
11
  from flash_context.mcp_server import run_mcp
12
12
  from flash_context.types import ExploreRequest, ExplorerError
13
13
 
14
+ _SCAN_LIMIT_WARNINGS = frozenset(
15
+ {
16
+ "glob scan budget exhausted",
17
+ "grep scan budget exhausted",
18
+ "read scan budget exhausted",
19
+ "grep match timed out",
20
+ }
21
+ )
22
+
14
23
 
15
24
  def main(argv: Sequence[str] | None = None) -> int:
16
25
  parser = _build_parser()
@@ -60,6 +69,12 @@ def _run_explore(args: argparse.Namespace) -> int:
60
69
  print(json.dumps(result.to_dict(), indent=2, sort_keys=True))
61
70
  else:
62
71
  print(format_text_result(result, citation=args.citation))
72
+ for warning in result.warnings:
73
+ print(f"WARNING: {warning}", file=sys.stderr)
74
+ if result.truncated and not result.warnings:
75
+ print("WARNING: Result truncated", file=sys.stderr)
76
+ if not result.citations and _SCAN_LIMIT_WARNINGS.intersection(result.warnings):
77
+ return 6
63
78
  return 0
64
79
 
65
80
 
@@ -50,16 +50,19 @@ class TrajectoryRecorder:
50
50
  self, turn: int, tool_name: str, observation: ToolObservation
51
51
  ) -> None:
52
52
  if self._traj_dir is not None:
53
- self._turns.append(
54
- {
55
- "turn": turn,
56
- "tool": tool_name,
57
- "ok": observation.ok,
58
- "truncated": observation.truncated,
59
- "hit_count": len(observation.hits),
60
- "content_length": len(observation.content or ""),
61
- }
62
- )
53
+ metadata: dict[str, object] = {
54
+ "turn": turn,
55
+ "tool": tool_name,
56
+ "ok": observation.ok,
57
+ "truncated": observation.truncated,
58
+ }
59
+ if tool_name == "read_file":
60
+ metadata["content_length"] = len(observation.content or "")
61
+ elif tool_name == "repo_grep":
62
+ metadata["hit_count"] = len(observation.hits)
63
+ elif tool_name == "repo_glob":
64
+ metadata["match_count"] = len(observation.matches)
65
+ self._turns.append(metadata)
63
66
 
64
67
  def finish(
65
68
  self, *, result: ExploreResult | None = None, error: ExplorerError | None = None
@@ -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(
@@ -60,6 +63,13 @@ def create_server() -> Any:
60
63
  max_turns: int | None = None,
61
64
  citation: bool | None = None,
62
65
  ) -> dict[str, object]:
66
+ """Find code or local text relevant to a focused repository question.
67
+
68
+ Explore read-only when implementation locations or supporting evidence
69
+ are unknown. Return validated file-line citations and bounded source
70
+ snippets, with warnings for incomplete exploration. repo_root defaults
71
+ to the server's working directory.
72
+ """
63
73
  return explore_repository_handler(
64
74
  query=query,
65
75
  repo_root=repo_root,
@@ -173,7 +173,31 @@ class PathSafety:
173
173
  except OSError:
174
174
  budget.warnings.append("search skipped an unreadable directory")
175
175
 
176
- yield from walk(self.repo_root)
176
+ directory = self.repo_root
177
+ parts = Path(pattern).parts
178
+ try:
179
+ for index, part in enumerate(parts):
180
+ if any(char in part for char in "*?["):
181
+ break
182
+ candidate = directory / part
183
+ budget.consume(entries=1)
184
+ self.ensure_allowed(candidate)
185
+ if index == len(parts) - 1:
186
+ if candidate.exists() and not candidate.is_dir():
187
+ yield self.resolve_existing_file(candidate)
188
+ return
189
+ # Match the walker's policy: never traverse directory symlinks.
190
+ if candidate.is_symlink() or not candidate.is_dir():
191
+ return
192
+ directory = candidate
193
+ yield from walk(directory)
194
+ except ExplorerError as exc:
195
+ if exc.code == "TOOL_LIMIT_EXCEEDED":
196
+ raise
197
+ if exc.code not in {"PATH_DENIED", "PATH_OUTSIDE_ROOT"}:
198
+ budget.warnings.append("search skipped an unreadable entry")
199
+ except OSError:
200
+ budget.warnings.append("search skipped an unreadable directory")
177
201
 
178
202
 
179
203
  def _matches_patterns(rel_path: str, patterns: tuple[str, ...]) -> bool:
@@ -49,14 +49,12 @@ class Citation:
49
49
  path: str
50
50
  start_line: int | None = None
51
51
  end_line: int | None = None
52
- reason: str | None = None
53
52
 
54
53
  def to_dict(self) -> dict[str, object]:
55
54
  return {
56
55
  "path": self.path,
57
56
  "start_line": self.start_line,
58
57
  "end_line": self.end_line,
59
- "reason": self.reason,
60
58
  }
61
59
 
62
60
  def label(self) -> str:
File without changes
File without changes